(oauth2)授权、资源服务器

oauth2 授权、资源服务器

测试代码地址

gitee.com/w--kk/test_...

Spring Security对OAuth2的支持

Spring Security 对 OAuth2 提供了很好的支持,这使得我们在 Spring Security中使用 OAuth2 非常地方便。然而由于历史原因,Spring Seaurity对 OAuth2 的支持比较混乱,这里简单梳理一下。

大约十年前,Spring 引入了一个社区驱动的开源项目 Spring Security OAuth, 并将其纳入 Spring 项目组合中。到今天为止,这个项目己经发展成为一个成熟的项目,可以支持大部分OAuth 规范,包括资源服务器、 客户端和授权服务器等。

然而早期的项目存在一些问题,例如:

  • OAuth 是在早期完成的,开发者无法预料未来的变化以及这些代码到底要被怎么使用, 这导致很多 Spring 项目提供了自己的 OAuth 支持,也就带来了 OAuth 支持的碎片化。

  • 最早的OAuth项目同时支特 OAuth1.0 和 OAuth2.0,而现在OAuth1.0 早已经不再使用, 可以放弃了。

  • 现在我们有更多的库可以选择,可以在这些库的基础上去开发,以便更好地支持JWT等新技术。

基于以上这些原因,官方决定重写 Spring Security OAuth, 以便更好地协调 Spring 和OAuth,并简化代码库,使Spring 的 OAuth 支持更加灵活。然而,在重写的过程中,发生了不少波折。

2018年1月30日,Spring 官方发了一个通知,表示要逐渐停止现有的 OAuth2支持,然后在 Spring Security 5中构建下一代 OAuth2.0 支持。这么做的原因是因为当时 OAuth2 的落地方案比较混乱,在 Spring Security OAuth、 Spring Cloud Security、Spring Boot 1.5.x 以及当时最新的Spring Security 5.x 中都提供了对 OAuth2 的实现。以至于当开发者需要使用 OAuth2 时,不得不问,到底选哪一个依赖合适呢?

所以Spring 官方决定有必要将 OAuth2.0 的支持统一到一个项目中,以便为用户提供明确的选择,并避免任何潜在的混乱,同时 OAuth2.0 的开发文档也要重新编写,以方便开发人员学习。所有的决定将在 Spring Security 5 中开始,构建下一代 OAuth2.0的支持。从那个时候起,Spring Security OAuth 项目就正式处于维护模式。官方将提供至少一年的错识/安全修复程序,并且会考虑添加次要功能,但不会添加主要功能。同时将 Spring Security OAuth中的所有功能重构到 Spring Security 5.x 中。

到了2019年11月14日,Spring 官方又发布一个通知,这次的通知首先表示 Spring Security OAuth 在迁往 Spring Security 5.x 的过程非常顺利,大部分迁程工作已经完成了,剩下的将在5.3 版本中完成迁移,在迁移的过程中还添加了许多新功能。包括对 OpenID Connect1.0 的支持。同时还宣布将不再支持授权服务器,不支持的原因有两个:

  1. 在2019年,已经有大量的商业和开源授权服务器可用。
  2. 授权服务器是使用一个库来构建产品,而 Spring Security 作为框架,并不适合做这件事情。

一石激起千层浪,许多开发者表示对此难以接受。这件事也在Spring 社区引发了激烈的讨论,好在 Spring 官方愿意倾听来自社区的声音。

到了2020年4月15日,Spring 官方宣布启动 Spring Authorization server 项目。这是一个由 Spring Security 团队领导的社区驱动的项目,致力于向 Spring 社区提供 Authorization Server支持,也就是说,Spring 又重新支持授权服务器了。

2020年8月21日,Spring Authorization Server 0.0.1 正式发布!

这就是 OAuth2 在Spring 家族中的发展历程了。在后面的学习中,客户端和资源服务器都将采用最新的方式来构建,授权服务器依然采用旧的方式来构建,因为目前的 Spring Authorization Server 0.0.1 功能较少且 BUG 较多

一般来说,当我们在项目中使用 OAuth2 时,都是开发客户端 ,授权服务器和资源服务器都是由外部提供。例如我们想在自己搭建网站上集成 GitHub 第三方登录,只需要开发自己的客户端即可,认证服务器和授权服务器都是由 GitHub 提供的

前言

对于大部分的开发者而言,日常接触到的 OAuth2 都是开发客户端,例如接入 QQ 登录、接入微信登录等。不过也有少量场景,可能需要开发者提供授权服务器与资源服务器,接下来我们就通过一个完整的案例演示如何搭建授权服务器与资源服务器。

搭建授权服务器,我们可以选择一些现成的开源项目,直接运行即可,例如:

  • Keycloak: RedFat 公司提供的开源工具,提供了很多实用功能,倒如单点登录、支持OpenID、可视化后台管理等。
  • Apache Oltu: Apache 上的开源项目,最近几年没怎么维护了。

接下来搭建一个包含授权服务器、资源服务器以及客户端在内的 OAuth2 案例。

项目规划首先把项目分为三部分:

  • 授权服务器:采用较早的 spring-cloud-starter-oauth2 来搭建授权服务器。
  • 资源服务器:采用最新的 Spring Security 5.x 搭建资源服务器。
  • 客户端: 采用最新的 Spring Security5.x 搭建客户端。

授权服务器搭建

流程

oauth2授权总体流程

角色梳理: 第三方应用 <----> 存储用户私密信息应用 ----> 授权服务器 ----> 资源服务器

整体流程如下:

(A)用户打开客户端以后,客户端要求用户给予授权。

(B)用户同意给予客户端授权。

(C)客户端使用上一步获得的授权,向认证服务器申请令牌。

(D)认证服务器对客户端进行认证以后,确认无误,同意发放令牌。

(E)客户端使用令牌,向资源服务器申请获取资源。

(F)资源服务器确认令牌无误,同意向客户端开放资源。

分析:两部分

  1. 首先需要在授权服务器(如github)上注册客户端,所以自己搭建的授权服务器也需要去包含客户端信息,所以授权服务器需要写入客户端信息。

  2. 必须先登录到授权服务器(如github)才能去授权。

1. 基于内存客户端和令牌存储

创建 springboot 应用,并引入依赖
注意: 降低 springboot 版本为 2.2.5.RELEASE (初学用很老的技术)

java 复制代码
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-oauth2</artifactId>
  <version>2.2.5.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

编写配置类,添加 security 配置类以及 oauth 配置类

Spring Security 配置类:

java 复制代码
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    @Override
    @Bean
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }
    @Bean
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
        UserDetails user = User.withUsername("root").password(passwordEncoder().encode("123")).roles("ADMIN").build();
        inMemoryUserDetailsManager.createUser(user);
        return inMemoryUserDetailsManager;
    }
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService());
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().formLogin();
    }
}

Authorization Server 配置类:

java 复制代码
@Configuration
@EnableAuthorizationServer
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
  
    private final PasswordEncoder passwordEncoder;

    private final UserDetailsService userDetailsService;

    @Autowired
    public AuthorizationServer(PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) {
        this.passwordEncoder = passwordEncoder;
        this.userDetailsService = userDetailsService;
    }

    /**
     * 配置客户端细节 如 客户端 id 秘钥 重定向 url 等
     *
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory().withClient("client")
                .secret(passwordEncoder.encode("secret"))
                .redirectUris("http://www.baidu.com")
                .scopes("client:read,user:read")
                .authorizedGrantTypes("authorization_code", "refresh_token","implicit","password","client_credentials");
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.userDetailsService(userDetailsService);//开启刷新令牌必须指定
    }
}

启动服务,登录之后进行授权码获取

完整路径: http://localhost:8080/oauth/authorize?client_id=client&response_type=code&redirect_uri=http://www.baidu.com

拿到授权码

授权之后根据获取的授权码获取令牌

完整路径:

java 复制代码
curl -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "grant_type=authorization_code&code=VFjvXY&redirect_uri=http://www.baidu.com" "http://client:secret@localhost:8080/oauth/token"

拿到令牌

curl也能在postman中执行 http://client:secret@localhost:8080/oauth/token

授权服务之刷新令牌

可以不用授权码了,使用令牌刷新

完整路径:

java 复制代码
curl -X POST -H "Content-Type: application/x-www-form-urlencoded" -d 'grant_type=refresh_token&refresh_token=f6583d8a-598c-46bb-81d8-01fa6484cf05&client_id=client' "http://client:secret@localhost:8080/oauth/token"

简化模式

授权服务器目前不支持简化模式,因为简化模式需要响应一段脚本,通过脚本的方式进行解析。

密码模式

2. 基于数据库客户端和令牌存储

在上面的案例中,TokenStore 的默认实现为 InMemoryTokenStore 即内存存储,对于 Client 信息,ClientDetailsService 接口负责从存储仓库中读取数据,在上面的案例中默认使用的也是 InMemoryClientDetailsService 实现类。

如果要想使用数据库存储,只要提供这些接口的实现类即可,而框架已经为我们写好 JdbcTokenStore 和 JdbcClientDetailsService

建表:
github.com/spring-proj...

注意: 用 BLOB 替换语句中的 LONGVARBINARY 类型

java 复制代码
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for clientdetails
-- ----------------------------
DROP TABLE IF EXISTS `clientdetails`;
CREATE TABLE `clientdetails` (
  `appId` varchar(256) NOT NULL,
  `resourceIds` varchar(256) DEFAULT NULL,
  `appSecret` varchar(256) DEFAULT NULL,
  `scope` varchar(256) DEFAULT NULL,
  `grantTypes` varchar(256) DEFAULT NULL,
  `redirectUrl` varchar(256) DEFAULT NULL,
  `authorities` varchar(256) DEFAULT NULL,
  `access_token_validity` int(11) DEFAULT NULL,
  `refresh_token_validity` int(11) DEFAULT NULL,
  `additionalInformation` varchar(4096) DEFAULT NULL,
  `autoApproveScopes` varchar(256) DEFAULT NULL,
  PRIMARY KEY (`appId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Table structure for oauth_access_token
-- ----------------------------
DROP TABLE IF EXISTS `oauth_access_token`;
CREATE TABLE `oauth_access_token` (
  `token_id` varchar(256) DEFAULT NULL,
  `token` blob,
  `authentication_id` varchar(256) NOT NULL,
  `user_name` varchar(256) DEFAULT NULL,
  `client_id` varchar(256) DEFAULT NULL,
  `authentication` blob,
  `refresh_token` varchar(256) DEFAULT NULL,
  PRIMARY KEY (`authentication_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Table structure for oauth_approvals
-- ----------------------------
DROP TABLE IF EXISTS `oauth_approvals`;
CREATE TABLE `oauth_approvals` (
  `userId` varchar(256) DEFAULT NULL,
  `clientId` varchar(256) DEFAULT NULL,
  `scope` varchar(256) DEFAULT NULL,
  `status` varchar(10) DEFAULT NULL,
  `expiresAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `lastModifiedAt` date DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Table structure for oauth_client_details
-- ----------------------------
DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details` (
  `client_id` varchar(256) NOT NULL,
  `resource_ids` varchar(256) DEFAULT NULL,
  `client_secret` varchar(256) DEFAULT NULL,
  `scope` varchar(256) DEFAULT NULL,
  `authorized_grant_types` varchar(256) DEFAULT NULL,
  `web_server_redirect_uri` varchar(256) DEFAULT NULL,
  `authorities` varchar(256) DEFAULT NULL,
  `access_token_validity` int(11) DEFAULT NULL,
  `refresh_token_validity` int(11) DEFAULT NULL,
  `additional_information` varchar(4096) DEFAULT NULL,
  `autoapprove` varchar(256) DEFAULT NULL,
  PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Table structure for oauth_client_token
-- ----------------------------
DROP TABLE IF EXISTS `oauth_client_token`;
CREATE TABLE `oauth_client_token` (
  `token_id` varchar(256) DEFAULT NULL,
  `token` blob,
  `authentication_id` varchar(256) NOT NULL,
  `user_name` varchar(256) DEFAULT NULL,
  `client_id` varchar(256) DEFAULT NULL,
  PRIMARY KEY (`authentication_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Table structure for oauth_code
-- ----------------------------
DROP TABLE IF EXISTS `oauth_code`;
CREATE TABLE `oauth_code` (
  `code` varchar(256) DEFAULT NULL,
  `authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Table structure for oauth_refresh_token
-- ----------------------------
DROP TABLE IF EXISTS `oauth_refresh_token`;
CREATE TABLE `oauth_refresh_token` (
  `token_id` varchar(256) DEFAULT NULL,
  `token` blob,
  `authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

SET FOREIGN_KEY_CHECKS = 1;

-- 写入客户端信息
INSERT INTO `oauth_client_details` VALUES ('client', NULL, '$2a$10$QCsINtuRfP8kM112xRVdvuI58MrefLlEP2mM0kzB5KZCPhnOf4392', 'read', 'authorization_code,refresh_token', 'http://www.baidu.com', NULL, NULL, NULL, NULL, NULL);

引入依赖

java 复制代码
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

编写配置文件

java 复制代码
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/oauth?characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=root

编写数据库信息实现

java 复制代码
@Configuration
@EnableAuthorizationServer
public class JdbcAuthorizationServer extends AuthorizationServerConfigurerAdapter {

    private final AuthenticationManager authenticationManager;


    private final PasswordEncoder passwordEncoder;

    private final DataSource dataSource;


    @Autowired
    public JdbcAuthorizationServer(AuthenticationManager authenticationManager, PasswordEncoder passwordEncoder, DataSource dataSource) {
        this.authenticationManager = authenticationManager;
        this.passwordEncoder = passwordEncoder;
        this.dataSource = dataSource;
    }

    @Bean // 声明TokenStore实现
    public TokenStore tokenStore() {
        return new JdbcTokenStore(dataSource);
    }

    @Bean // 声明 ClientDetails实现
    public ClientDetailsService clientDetails() {
        JdbcClientDetailsService jdbcClientDetailsService = new JdbcClientDetailsService(dataSource);
        jdbcClientDetailsService.setPasswordEncoder(passwordEncoder);
        return jdbcClientDetailsService;
    }

    @Override //配置使用数据库实现
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager);//认证管理器
        endpoints.tokenStore(tokenStore());//配置令牌存储为数据库存储

        // 配置TokenServices参数
        DefaultTokenServices tokenServices = new DefaultTokenServices();//修改默认令牌生成服务
        tokenServices.setTokenStore(endpoints.getTokenStore());//基于数据库令牌生成
        tokenServices.setSupportRefreshToken(true);//是否支持刷新令牌
        tokenServices.setReuseRefreshToken(true);//是否重复使用刷新令牌(直到过期)

        tokenServices.setClientDetailsService(endpoints.getClientDetailsService());//设置客户端信息
        tokenServices.setTokenEnhancer(endpoints.getTokenEnhancer());//用来控制令牌存储增强策略
        //访问令牌的默认有效期(以秒为单位)。过期的令牌为零或负数。
        tokenServices.setAccessTokenValiditySeconds((int) TimeUnit.DAYS.toSeconds(30)); // 30天
        //刷新令牌的有效性(以秒为单位)。如果小于或等于零,则令牌将不会过期
        tokenServices.setRefreshTokenValiditySeconds((int) TimeUnit.DAYS.toSeconds(3)); //3天
        endpoints.tokenServices(tokenServices);//使用配置令牌服务
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.withClientDetails(clientDetails());//使用 jdbc存储
    }
}

启动测试,发现数据库中已经存储相关的令牌

资源服务器搭建

流程

  • 授权服务器颁发令牌给客户端后,客户端要拿着令牌去访问资源服务器。

  • 资源服务器看到令牌后并不是立即返回受保护的资源,它要先校验令牌的合法性,校验令牌的合法性就需要与授权服务器进行通信。

  • 授权服务器拿到令牌后进行令牌的校验。

  • 第一种方式是 /oauth/check_token:用于资源服务访问的令牌解析端点(资源服务器拿到令牌后去授权服务器上请求对应的check_token,携带的token,看它是否是一个合法的token)。

    这种方式(通过http方式调请求去校验)存在问题,当资源服务器去请求授权服务器时,如果网络通信失败,即使是正确的token也拿不到结果。

  • 第二种方式(用的比较多的解决方案) 授权服务器上一步已经把令牌存进数据库中,如果资源服务器直接向数据库进行请求令牌,也就是资源服务器和授权服务器共同用一个数据库。在互联网应用中数据库往往不是一个,会分库分表,这时就需要用到redis去解决分布式系统中的共享问题,把令牌存储到redis中。如果资源服务器和授权服务器用不同的redis则只能用方式一httpclient进行令牌的校验。

引入依赖

除了resource-server,还是需要授权的依赖oauth2,因为也要根据授权的机制去操作数据库,读取相应的信息,或者去操作redis读取相应信息

java 复制代码
<properties>
        <java.version>1.8</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-boot.version>2.2.5.RELEASE</spring-boot.version>
        <spring-cloud.version>Hoxton.SR9</spring-cloud.version>

    </properties>

    <dependencies>
        <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>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-resource-server</artifactId>
        </dependency>


        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

创建资源

java 复制代码
@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello(){
        return "hello!";
    }
}

编写资源服务器配置类

java 复制代码
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    private final DataSource dataSource;
    @Autowired
    public ResourceServerConfig(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.tokenStore(tokenStore());
    }
    @Bean
    public TokenStore tokenStore() {
        return new JdbcTokenStore(dataSource);
    }
}

编写配置文件

java 复制代码
# 应用服务 WEB 访问端口
server.port=8081
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/security?characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=root
logging.level.org.springframework.jdbc.core=debug

访问资源服务器:提示需要认证
curl http://localhost:8081/hello

启动测试,生成令牌之后带有令牌访问:
curl -H "Authorization:Bearer d4954a4e-6f09-4e9e-b2ca-dcc0d39283a5" http://localhost:8081/hello

报错:

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"

原因:

SpringSecurity不允许明文的秘钥,必须指定一种加密的方式才行

Handling error: IllegalStateException, UserDetailsService is required.

解决 刷新令牌的时候要求必须以当前的userDetailService去自动登录

添加(在认证中已经写了userDetailService的bean了,所以这里直接注入)

Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true

解决

是因为同一个Bean在不同的class文件中被重复注册导致
curl介绍 😍 curl 是常用的命令行工具,用来请求 Web 服务器。它的名字就是客户端(client)的 URL 工具的意思。

它的功能非常强大,命令行参数多达几十种。如果熟练的话,完全可以取代 Postman 这一类的图形界面工具。

  1. 官网下载
    curl - Download

下载windows版本

  1. 解压和配置环境变量

CURL_HOME

%CURL_HOME%\bin

  1. 验证是否成功

cmd下执行: curl --version

报错:

解决:把路径中的单引号换成双引号

引用

  1. 编程不良人
  2. curl 的用法指南 - 阮一峰的网络日志 (ruanyifeng.com)
  3. windows下使用curl命令 && 常用curl命令 - Wayne-Zhu - 博客园 (cnblogs.com)
  4. Curl 下载安装和配置_curl安装-CSDN博客
相关推荐
小松学前端1 分钟前
第六章 7.0 LinkList
java·开发语言·网络
Wx-bishekaifayuan8 分钟前
django电商易购系统-计算机设计毕业源码61059
java·spring boot·spring·spring cloud·django·sqlite·guava
customer0812 分钟前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
全栈开发圈14 分钟前
新书速览|Java网络爬虫精解与实践
java·开发语言·爬虫
WaaTong17 分钟前
《重学Java设计模式》之 单例模式
java·单例模式·设计模式
面试鸭19 分钟前
离谱!买个人信息买到网安公司头上???
java·开发语言·职场和发展
沈询-阿里1 小时前
java-智能识别车牌号_基于spring ai和开源国产大模型_qwen vl
java·开发语言