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 小米 华为 单反 装机 图拉丁
 
   -> Java知识库 -> Spring Security 系列(2) —— Spring Security OAuth2 -> 正文阅读

[Java知识库]Spring Security 系列(2) —— Spring Security OAuth2

Spring Security OAuth2.0

文章目录

OAuth2 介绍

OAuth(开放授权)是一个开放标准,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。

在认证和授权的过程中涉及的三方包括:
1、服务提供方,用户使用服务提供方来存储受保护的资源,如照片,视频,联系人列表。
2、用户,存放在服务提供方的受保护的资源的拥有者。
3、客户端,要访问服务提供方资源的第三方应用,通常是网站,如提供照片打印服务的网站。在认证过程之前,客户端要向服务提供者申请客户端标识。

例如微信的第三方登陆,以京东的微信登陆为例,此时微信是服务的提供方,京东就是客户端。京东需要获取微信中用户存储的姓名与头像等身份信息。具体流程可以看下图

在这里插入图片描述

OAuth2 的授权模式

OAuth2.0有4种授权模式:

  • 授权码 (Authorization Code Grant 又称授权码模式)
  • 隐式授权 (Implicit Grant 又称简化模式)
  • RO凭证授权 (Resource Owner Password Credentials Grant 又称密码模式)
  • Client凭证授权 (Client Credentials Grant又称客户端模式)

授权码模式

授权代码授予类型用于获取访问令牌和刷新令牌,并针对机密客户端进行了优化。由于这是一个基于重定向的流,因此客户端必须能够与资源所有者的用户代理(通常是 Web 浏览器)进行交互,并且能够(通过重定向)从授权服务器接收传入的请求。


     +----------+
     | Resource |
     |   Owner  |
     |          |
     +----------+
          ^
          |
         (B)
     +----|-----+          Client Identifier      +---------------+
     |         -+----(A)-- & Redirection URI ---->|               |
     |  User-   |                                 | Authorization |
     |  Agent  -+----(B)-- User authenticates --->|     Server    |
     |          |                                 |               |
     |         -+----(C)-- Authorization Code ---<|               |
     +-|----|---+                                 +---------------+
       |    |                                         ^      v
      (A)  (C)                                        |      |
       |    |                                         |      |
       ^    v                                         |      |
     +---------+                                      |      |
     |         |>---(D)-- Authorization Code ---------'      |
     |  Client |          & Redirection URI                  |
     |         |                                             |
     |         |<---(E)----- Access Token -------------------'
     +---------+       (w/ Optional Refresh Token)

验证流程阐述:

  • (A) 客户端通过将资源所有者的用户代理定向到授权终结点来启动流。 客户端包括其客户端标识符、请求的作用域、本地状态和重定向 URI,授权服务器将在授予(或拒绝)访问权限后将用户代理发回该 URI。
  • (B) 授权服务器(通过用户代理)对资源所有者进行身份验证,并确定资源所有者是授予还是拒绝客户端的访问请求。
  • (C) 假定资源所有者授予访问权限,授权服务器使用前面提供的重定向 URI(在请求中或在客户端注册期间)将用户代理重定向回客户端。 重定向 URI 包括授权代码和客户端之前提供的任何本地状态
  • (D) 客户端通过包含上一步中收到的授权代码,从授权服务器的令牌终结点请求访问令牌。 发出请求时,客户端向授权服务器进行身份验证。 客户端包含用于获取用于获取用于验证的授权代码的重定向 URI。
  • (E) 授权服务器对客户端进行身份验证,验证授权代码,并确保收到的重定向 URI 与步骤 (C) 中用于重定向客户端的 URI 匹配。 如果有效,授权服务器将使用访问令牌和刷新令牌(可选)进行响应。

简化授权模式

隐式授权类型用于获取访问令牌(它支持颁发刷新令牌),并针对已知运行特定重定向 URI 的公共客户端进行了优化。 这些客户端通常使用脚本语言(如 JavaScript)在浏览器中实现。

由于这是一个基于重定向的流,因此客户端必须能够与资源所有者的用户代理(通常是 Web 浏览器)进行交互,并且能够(通过重定向)从授权服务器接收传入的请求。

与授权代码授予类型不同,在授权代码授予类型中,客户端对授权令牌和访问令牌发出单独的请求,客户端接收访问令牌作为授权请求的结果。

隐式授权类型不包括客户端身份验证,并且依赖于资源所有者的存在和重定向 URI 的注册。 由于访问令牌已编码到重定向 URI 中,因此可能会向资源所有者和驻留在同一设备上的其他应用程序公开访问令牌。

     +----------+
     | Resource |
     |  Owner   |
     |          |
     +----------+
          ^
          |
         (B)
     +----|-----+          Client Identifier     +---------------+
     |         -+----(A)-- & Redirection URI --->|               |
     |  User-   |                                | Authorization |
     |  Agent  -|----(B)-- User authenticates -->|     Server    |
     |          |                                |               |
     |          |<---(C)--- Redirection URI ----<|               |
     |          |          with Access Token     +---------------+
     |          |            in Fragment
     |          |                                +---------------+
     |          |----(D)--- Redirection URI ---->|   Web-Hosted  |
     |          |          without Fragment      |     Client    |
     |          |                                |    Resource   |
     |     (F)  |<---(E)------- Script ---------<|               |
     |          |                                +---------------+
     +-|--------+
       |    |
      (A)  (G) Access Token
       |    |
       ^    v
     +---------+
     |         |
     |  Client |
     |         |
     +---------+

验证流程阐述:

  • (A) 客户端通过将资源所有者的用户代理定向到授权终结点来启动流。 客户端包括其客户端标识符、请求的作用域、本地状态和重定向 URI,授权服务器将在授予(或拒绝)访问权限后将用户代理发回该 URI。
  • (B) 授权服务器(通过用户代理)对资源所有者进行身份验证,并确定资源所有者是授予还是拒绝客户端的访问请求。
  • ? 假定资源所有者授予访问权限,授权服务器将使用前面提供的重定向 URI 将用户代理重定向回客户端。 重定向 URI 在 URI 片段中包含访问令牌。
  • (D) 用户代理遵循重定向指令,向 Web 托管的客户机资源发出请求。 用户代理在本地保留片段信息。
  • (E) Web 托管的客户机资源返回一个网页(通常是带有嵌入式脚本的 HTML 文档),该网页能够访问完整的重定向 URI,包括用户代理保留的片段,并提取片段中包含的访问令牌(和其他参数)。
  • (F) 用户代理在本地执行 Web 托管的客户机资源提供的脚本,该脚本提取访问令牌。
  • (G) 用户代理将访问令牌传递给客户端。

密码模式

资源所有者密码凭据授予类型适用于资源所有者与客户端(如设备操作系统或特权应用程序)建立信任关系的情况。 授权服务器在启用此授权类型时应特别小心,并且仅在其他流不可行时才允许它。

此授权类型适用于能够获取资源所有者凭据(用户名和密码,通常使用交互式表单)的客户端。 它还用于使用直接身份验证方案(如 HTTP 基本或摘要)迁移现有客户端。 通过将存储的凭据转换为访问令牌来对 OAuth 进行身份验证。


     +----------+
     | Resource |
     |  Owner   |
     |          |
     +----------+
          v
          |    Resource Owner
         (A) Password Credentials
          |
          v
     +---------+                                  +---------------+
     |         |>--(B)---- Resource Owner ------->|               |
     |         |         Password Credentials     | Authorization |
     | Client  |                                  |     Server    |
     |         |<--(C)---- Access Token ---------<|               |
     |         |    (w/ Optional Refresh Token)   |               |
     +---------+                                  +---------------+

验证流程阐述:

  • (A) 资源所有者向客户端提供其用户名和密码。
  • (B) 客户端通过包含从资源所有者处收到的凭据,从授权服务器的令牌终结点请求访问令牌。 发出请求时,客户端向授权服务器进行身份验证。
  • ? 授权服务器对客户端进行身份验证并验证资源所有者凭据,如果有效,则颁发访问令牌。

客户端模式

在客户端模式下,客户端仅需要发送客户端自己的凭证 (或其他支持的验证方式) 就可以请求并获取到一个 access token (令牌)。客户端可以拿着 token 去请求在其控制下的受保护的资源,或者其他资源所有者先前安排给资源服务器的资源。

     +---------+                                  +---------------+
     |         |                                  |               |
     |         |>--(A)- Client Authentication --->| Authorization |
     | Client  |                                  |     Server    |
     |         |<--(B)---- Access Token ---------<|               |
     |         |                                  |               |
     +---------+                                  +---------------+

验证流程阐述:

  • (A) 客户端使用授权服务器进行验证
  • (B) 授权服务器对客户端的信息进行验证,如果是合法的则签发一个 access token

OAuth2 刷新令牌

刷新令牌是用于获取访问令牌的凭据。 刷新令牌由授权服务器颁发给客户端,用于在当前访问令牌无效或过期时获取新的访问令牌,或者获取具有相同或更窄范围的其他访问令牌(访问令牌的生存期可能比资源所有者授权的权限短,权限更少)。 颁发刷新令牌是可选的,由授权服务器自行决定。 如果授权服务器颁发刷新令牌,则在颁发访问令牌时会包含刷新令牌(即图 1 中的步骤 (D) )。

刷新令牌是一个字符串,表示资源所有者授予客户端的授权。 该字符串通常对客户端不透明。 令牌表示用于检索授权信息的标识符。 与访问令牌不同,刷新令牌仅用于授权服务器,从不发送到资源服务器。

  +--------+                                           +---------------+
  |        |--(A)------- Authorization Grant --------->|               |
  |        |                                           |               |
  |        |<-(B)----------- Access Token -------------|               |
  |        |               & Refresh Token             |               |
  |        |                                           |               |
  |        |                            +----------+   |               |
  |        |--(C)---- Access Token ---->|          |   |               |
  |        |                            |          |   |               |
  |        |<-(D)- Protected Resource --| Resource |   | Authorization |
  | Client |                            |  Server  |   |     Server    |
  |        |--(E)---- Access Token ---->|          |   |               |
  |        |                            |          |   |               |
  |        |<-(F)- Invalid Token Error -|          |   |               |
  |        |                            +----------+   |               |
  |        |                                           |               |
  |        |--(G)----------- Refresh Token ----------->|               |
  |        |                                           |               |
  |        |<-(H)----------- Access Token -------------|               |
  +--------+           & Optional Refresh Token        +---------------+

刷新令牌流程阐述:

  • (A) 客户端通过向授权服务器进行身份验证并提交授权授予来请求访问令牌。
  • (B) 授权服务器对客户端进行身份验证并验证授权授予,如果有效,则颁发访问令牌和刷新令牌。
  • (C) 客户端通过提供访问令牌向资源服务器发出受保护的资源请求。
  • (D) 资源服务器验证访问令牌,如果有效,则为请求提供服务。
  • (E) 重复步骤 (C) 和 (D),直到访问令牌过期。 如果客户端知道访问令牌已过期,它将跳到步骤 (G);否则,它会发出另一个受保护的资源请求。
  • (F) 由于访问令牌无效,资源服务器将返回无效令牌错误。
  • (G) 客户端通过向授权服务器进行身份验证并提供刷新令牌来请求新的访问令牌。 客户端身份验证要求基于客户端类型和授权服务器策略。
  • (H) 授权服务器对客户端进行身份验证并验证刷新令牌,如果有效,则颁发新的访问令牌(以及可选的新刷新令牌)。

Spring Security OAuth2 自定义授权服务器

引入 Maven 依赖

pom.xml

        <!-- 引入 OAuth2 核心包 -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-core</artifactId>
        </dependency>
        <!-- 引入 OAuth2 客户端依赖 -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-client</artifactId>
        </dependency>
        <!-- 引入资源服务器依赖 -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-resource-server</artifactId>
        </dependency>
        <!-- 引入授权服务器依赖 -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-config</artifactId>
        </dependency>

applicaton.yml

server:  
    port: 8088

授权码模式

配置 WebSecurityConfigurerAdapter

WebSecurityConfigurerAdapter

@Configuration
@EnableWebSecurity
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
    // Step2: 重写 configure 方法
    @Override
    public void configure(HttpSecurity http) throws Exception{
        http.formLogin().permitAll()
               .successForwardUrl("/loginSuccess"); // 登陆成功时跳转的url
        http.authorizeRequests()
                .antMatchers("/oauth/**").permitAll() // 通过所有 OAuth2 请求
                .antMatchers(HttpMethod.POST,"/login").permitAll() // 通过 login 请求
                .anyRequest().authenticated();
        http.logout().permitAll();
        http.csrf().disable();
    }
    // 注入密码加密器
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    // 注入创建的 UserService
    @Bean
    public MyUserDetailsService myUserDetailsService(){
        return new MyUserDetailsService();
    }
    // 重写 Configure 方法,使配置生效
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(myUserDetailsService()) // 使用自定义的 UserDetailService
                .passwordEncoder(passwordEncoder()); // 指定校验时使用的密码加密器
    }
    // 将代理 AuthenticationManager 注册成 Bean,供直接使用
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

定义自己的 UserDetailsService

UserDetailsService

public class MyUserDetailsService implements UserDetailsService{
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        String password = "kgtdata";
        String pwdCrypt = passwordEncoder.encode(password);// 模拟从数据中取出密码
        List<SimpleGrantedAuthority> authorityList = new ArrayList<>();
        authorityList.add(new SimpleGrantedAuthority("ROLE_user"));
        // 这里只是模拟,实际可以与数据库进行交互获得用户权限
        UserDetails user = new User(username,pwdCrypt,authorityList);
        return user;
    }
}

创建认证服务器

AuthorizationServerConfigurerAdapter

@Configuration
@EnableAuthorizationServer
public class MyAuthorizationServerConfigurerAdapter extends AuthorizationServerConfigurerAdapter {
    @Autowired
    PasswordEncoder passwordEncoder;// 为设置密钥添加加密器
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception{// 配置授权服务器有关客户端的配置
        clients.inMemory()
                .withClient("client") // 设置 client_Id
                .secret(passwordEncoder.encode("yourSecret"))// 配置 client_secret
                .redirectUris("http://www.baidu.com") // 设置重定向的 uri
                .scopes("all")// 设置授权的作用域
                .authorizedGrantTypes("authorization_code"); // 设置授权类型为授权码模式
    }
    
    @Override
    public void configure(AuthorizationServerSecurityConfigurer oauthServer){// 配置验证服务器相关设置
       oauthServer.tokenKeyAccess("permitAll()")
               .checkTokenAccess("permitAll()")
               .allowFormAuthenticationForClients();  // 允许来自客户端的表单验证
    }
}

创建资源服务器

ResourceServerConfigurerAdapter

@Configuration
@EnableResourceServer
public class MyResourceServerConfigurerAdapter extends ResourceServerConfigurerAdapter {
    @Override
    public void configure(HttpSecurity http) throws Exception{
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .requestMatchers().antMatchers("/user/**");
    }
}

定义两个 Controller

UserController

@RestController
public class UserController {
    @PostMapping("/user/info")
    public String info(){
        return "The User Info !!!";
    }
}

LoginController

@RestController
public class LoginController {
    @PostMapping("/loginSuccess")
    public String login(){
        return "Sucess !!!";
    }
}

测试效果

设置验证参数

在这里插入图片描述

设置请求参数

在这里插入图片描述

Postman 调用结束后可以看见

在这里插入图片描述

设置验证的token

在这里插入图片描述

然后得到相应的返回
在这里插入图片描述

简化模式实现

修改认证服务器代码

AuthorizationServerConfigurerAdapter

@Configuration
@EnableAuthorizationServer
public class MyAuthorizationServerConfigurerAdapter extends AuthorizationServerConfigurerAdapter {
    @Autowired
    PasswordEncoder passwordEncoder;// 为设置密钥添加加密器
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception{// 配置授权服务器有关客户端的配置
        clients.inMemory()
                .withClient("client") // 设置 client_Id
                .secret(passwordEncoder.encode("yourSecret"))// 配置 client_secret
                .redirectUris("http://www.baidu.com") // 设置重定向的 uri
                .scopes("all")// 设置授权的作用域
                .accessTokenValiditySeconds(3600).refreshTokenValiditySeconds(78000)
                .authorizedGrantTypes("authorization_code","implicit"); // <- 这里添加一个 implicit 代表添加简化模式
    }
    
    @Override
    public void configure(AuthorizationServerSecurityConfigurer oauthServer){// 配置验证服务器相关设置
       oauthServer.tokenKeyAccess("permitAll()")
               .checkTokenAccess("permitAll()")
               .allowFormAuthenticationForClients();  // 允许来自客户端的表单验证
    }
}

测试效果

通过授权后可以看见网址栏中返回了 token

https://www.baidu.com/#access_token=442e830a-e488-47eb-b78e-1626d64097b9&token_type=bearer&expires_in=3458

密码模式实现

修改认证服务器

AuthorizationServerConfigurerAdapter

@Configuration
@EnableAuthorizationServer // 开启认证服务器功能
public class MyAuthorizationServerConfigurerAdapter extends AuthorizationServerConfigurerAdapter {
    @Autowired
    PasswordEncoder passwordEncoder;// 为设置密钥添加加密器
    @Autowired
    AuthenticationManager authenticationManager; // 注入一个 authenticationManager
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception{
        endpoints.authenticationManager(authenticationManager) // 使用密码模式时,需要为 Endpoint 添加authenticationManager
                .allowedTokenEndpointRequestMethods(HttpMethod.POST,HttpMethod.GET);
    }
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception{// 配置授权服务器相关配置
        clients.inMemory()
                .withClient("client") // 配置 client_id
                .secret(passwordEncoder.encode("yourSecret"))// 配置 client_secret
                .redirectUris("http://www.baidu.com") // 设置重定向的 uri
                .scopes("all")// 设置授权的作用域
                .accessTokenValiditySeconds(3600)
                .refreshTokenValiditySeconds(78000)
                .authorizedGrantTypes("authorization_code","implicit","password"); // <- 添加密码授权模式 password
    }
    @Override
    public void configure(AuthorizationServerSecurityConfigurer oauthServer){// 配置验证服务器相关设置
       oauthServer.tokenKeyAccess("permitAll()")
               .checkTokenAccess("permitAll()")
               .allowFormAuthenticationForClients();
    }
}

测试效果

然后可以看见返回了如下内容

{"access_token":"5a8a0e3f-b389-41a6-beec-26be5aef977e","token_type":"bearer","expires_in":3599,"scope":"all"}

客户端模式实现

修改认证服务器

AuthorizationServerConfigurerAdapter

@Configuration
@EnableAuthorizationServer // 开启认证服务器功能
public class MyAuthorizationServerConfigurerAdapter extends AuthorizationServerConfigurerAdapter {
    @Autowired
    PasswordEncoder passwordEncoder;// 为设置密钥添加加密器
    @Autowired
    AuthenticationManager authenticationManager; // 注入一个 authenticationManager
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception{
        endpoints.authenticationManager(authenticationManager) // 使用密码模式时,需要为 Endpoint 添加authenticationManager
                .allowedTokenEndpointRequestMethods(HttpMethod.POST,HttpMethod.GET);
    }
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception{// 配置授权服务器相关配置
        clients.inMemory()
                .withClient("client") // 配置 client_id
                .secret(passwordEncoder.encode("yourSecret"))// 配置 client_secret
                .redirectUris("http://www.baidu.com") // 设置重定向的 uri
                .scopes("all")// 设置授权的作用域
                .accessTokenValiditySeconds(3600)
                .refreshTokenValiditySeconds(78000)
                .authorizedGrantTypes("authorization_code","implicit","password","client_credentials"); // <- 添加客户端授权模式 client_credentials
    }
    @Override
    public void configure(AuthorizationServerSecurityConfigurer oauthServer){// 配置验证服务器相关设置
       oauthServer.tokenKeyAccess("permitAll()")
               .checkTokenAccess("permitAll()")
               .allowFormAuthenticationForClients();
    }
}

测试效果

然后可以看见返回

{"access_token":"9ac4f34a-aebe-4ed2-9037-e5e906c76b81","token_type":"bearer","expires_in":3599,"scope":"all"}

更新 Token 实现

修改认证服务器

AuthorizationServerConfigurerAdapter

@Configuration
@EnableAuthorizationServer // 开启认证服务器功能
public class MyAuthorizationServerConfigurerAdapter extends AuthorizationServerConfigurerAdapter {
    @Autowired
    PasswordEncoder passwordEncoder;// 为设置密钥添加加密器
    @Autowired
    AuthenticationManager authenticationManager; // 注入一个 authenticationManager
    @Autowired
    MyUserDetailsService userDetailsService; // 注入一个 UserDetailsService
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception{
        endpoints.authenticationManager(authenticationManager) // 使用密码模式时,需要为 Endpoint 添加authenticationManager
                .reuseRefreshTokens(false) // 设置 refresh token 是否重复使用
                .userDetailsService(userDetailsService) //  设置 refresh token 对用户信息的检查
                .allowedTokenEndpointRequestMethods(HttpMethod.POST,HttpMethod.GET);
    }
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception{// 配置授权服务器相关配置
        clients.inMemory()
                .withClient("client") // 配置 client_id
                .secret(passwordEncoder.encode("yourSecret"))// 配置 client_secret
                .redirectUris("http://www.baidu.com") // 设置重定向的 uri
                .scopes("all")// 设置授权的作用域
                .accessTokenValiditySeconds(3600)
                .refreshTokenValiditySeconds(78000)
                .authorizedGrantTypes("authorization_code","implicit","password","client_credentials","refresh_token"); // <- 添加 refresh_token 授权类型
    }
    @Override
    public void configure(AuthorizationServerSecurityConfigurer oauthServer){// 配置验证服务器相关设置
       oauthServer.tokenKeyAccess("permitAll()")
               .checkTokenAccess("permitAll()")
               .allowFormAuthenticationForClients();
    }
}

测试效果

  • 首先用密码模式获取 access_token 和 refresh_token

在浏览器中输入 http://localhost:8088/oauth/token?grant_type=password&client_id=client&scope=all&client_secret=yourSecret&username=user&password=myPassword

然后可以看到浏览器返回

{"access_token":"32d96307-db25-4c36-b303-6b065cc863b2","token_type":"bearer","refresh_token":"b6489e37-0b33-4158-93c7-13746c74b134","expires_in":3599,"scope":"all"}
  • 然后用 refresh_token 请求新的 token

浏览器中输入 http://localhost:8088/oauth/token?grant_type=refresh_token&client_id=client&client_secret=yourSecret&refresh_token=b6489e37-0b33-4158-93c7-13746c74b134

然后浏览器会返回新的 access_token 和 refresh_token

{"access_token":"6a79f640-d4ad-4f60-aba0-f3b5f1d0087b","token_type":"bearer","refresh_token":"eac5efe2-5ce3-4a21-82c3-bf67fdd626da","expires_in":3600,"scope":"all"}

引入 JWT

什么是 JWT

JWT (Json Web Token)是一种提议的 Internet 标准,用于创建具有可选签名和/或可选加密的数据,其有效负载包含声明一些声明的JSON . 令牌使用私有密钥或公钥/私钥进行签名。

JWT 的组成

JWT的token是三段由小数点分隔组成的字符串:header.payload.signature,即头部、载荷与签名。

header

Header header典型的由两部分组成:token的类型(“JWT”)和算法名称(比如:HMAC SHA256或者RSA等等)。

【示例】

{
    'alg': "HS256",
    'typ': "JWT"
}
payload

Payload JWT的第二部分是payload,它包含声明(要求)。声明是关于实体(通常是用户)和其他数据的声明。声明有三种类型: registered, public 和 private。

  • Registered claims : 这里有一组预定义的声明,它们不是强制的,但是推荐。比如:iss (issuer), exp (expiration time), sub (subject), aud (audience)等。
  • Public claims : 定义新创的信息,比如用户信息和其他重要信息
  • Private claims : 用于在同意使用它们的各方之间共享信息,并且不是注册的或公开的声明。

JWT规定7个官方字段,供选用:

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):面向的用户
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号,唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
Signature

签名是用于验证消息在传递过程中有没有被更改,并且,对于使用私钥签名的token,它还可以验证JWT的发送方是否为它所称的发送方。

Signature 由 Header 指定的算法 HS256 加密产生。该算法有两个参数,第一个参数是经过 Base64 分别编码的 Header 及 Payload 通过 . 连接组成的字符串,第二个参数是生成的密钥,由服务器保存。

 $Signature = HS256(Base64(Header) + "." + Base64(Payload), secretKey)
 
JWT = Base64(Header) + "." + Base64(Payload) + "." + $Signature

整合 JWT

Maven 引入

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security.oauth</groupId>
            <artifactId>spring-security-oauth2</artifactId>
            <version>2.3.1.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-jwt</artifactId>
            <version>1.0.7.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>

创建 JWT 写相关配置类

@Configuration
public class JWTTokenStoreConfig {
    @Bean
    JwtAccessTokenConverter jwtAccessTokenConverter(){
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        jwtAccessTokenConverter.setSigningKey("yourKey");   // 配置 jwt 秘钥
        return jwtAccessTokenConverter;
    }
    @Bean
    public TokenStore tokenStore(){
        return new JwtTokenStore(jwtAccessTokenConverter()); // 注册一个 JWTTokenStore 对象
    }
}

修改 OAuth2 授权服务器配置

AuthorizationServerConfigurerAdapter

    @Autowired
    JwtAccessTokenConverter jwtAccessTokenConverter; // 注入 TokenConverter
    @Autowired
    @Qualifier("JwtTokenStore")
    private TokenStore tokenStore;     // 注入 TokenStore
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception{
        endpoints.authenticationManager(authenticationManager) // 使用密码模式时,需要为 Endpoint 添加authenticationManager
                .reuseRefreshTokens(false) // 设置 refresh token 是否重复使用
                .userDetailsService(userDetailsService) //  设置 refresh token 对用户信息的检查
                .allowedTokenEndpointRequestMethods(HttpMethod.POST,HttpMethod.GET)
                .tokenStore(tokenStore) // 指定 tokenStore;
                .accessTokenConverter(jwtAccessTokenConverter); // 指定 tokenConverter
    }

测试效果

这里直接使用密码模式进行 token 请求
浏览器直接访问 http://localhost:8088/oauth/token?grant_type=password&client_id=client&scope=all&client_secret=yourSecret&username=user&password=password

最后我们可以看见效果

{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NTIxNjQwNDgsInVzZXJfbmFtZSI6InVzZXIiLCJhdXRob3JpdGllcyI6WyJST0xFX3VzZXIiXSwianRpIjoiNjY3MTIxZWYtNDU0OS00NmUyLTk2MmQtNTg3OTI5ZmJkNzk4IiwiY2xpZW50X2lkIjoiY2xpZW50Iiwic2NvcGUiOlsiYWxsIl19.S5qOsDu2OhQhuZxM14QwkR1Vn29KxU7rmFKiCaphidw","token_type":"bearer","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJ1c2VyIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6IjY2NzEyMWVmLTQ1NDktNDZlMi05NjJkLTU4NzkyOWZiZDc5OCIsImV4cCI6MTY1MjIzODQ0OCwiYXV0aG9yaXRpZXMiOlsiUk9MRV91c2VyIl0sImp0aSI6IjM3NTk5ODhlLTJmNGEtNDBjNC1hNTA1LWZhMDViY2QzYTA2MyIsImNsaWVudF9pZCI6ImNsaWVudCJ9.piNiGQ_E7wH1zprndJYsUsdz-nZ2SdP0V-QWH1igqNY","expires_in":3599,"scope":"all","jti":"667121ef-4549-46e2-962d-587929fbd798"}

扩展 JWT 中的内容

创建一个 JWT 增强类

public class JwtTokenEnhancer implements TokenEnhancer {  // 创建一个 Token 增强器,并实现接口
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) {
        Map<String,Object> info = new HashMap<>(); // 创建一个 Map ,用于存储额外添加到 Token 中的信息
        info.put("enhance","enhance info");
        ((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(info);
        return oAuth2AccessToken;
    }
}

在刚才自定义的 JWT 配置类中将 JwtTokenEnhancer 注册成 Bean

@Bean
public JwtTokenEnhancer tokenEnhancer(){    return new JwtTokenEnhancer();}

修改 OAuth2 授权服务器配置

AuthorizationServerConfigurerAdapter

    @Autowired
    JwtTokenEnhancer tokenEnhancer;
    
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception{
        endpoints.authenticationManager(authenticationManager) // 使用密码模式时,需要为 Endpoint 添加authenticationManager
                .reuseRefreshTokens(false) // 设置 refresh token 是否重复使用
                .userDetailsService(userDetailsService) //  设置 refresh token 对用户信息的检查
                .allowedTokenEndpointRequestMethods(HttpMethod.POST,HttpMethod.GET);
        endpoints.tokenStore(tokenStore)
                .accessTokenConverter(jwtAccessTokenConverter);
                
        TokenEnhancerChain chain = new TokenEnhancerChain(); // 创建一个 TokenEnhancerChain 对象用于存放 TokenEnhancer 列表
        List<TokenEnhancer> enhancerList = new ArrayList<>(); // 创建一个 TokenEnhancer 列表
        enhancerList.add(tokenEnhancer);  // 将自定的 TokenEnhancer 放入到列表中
        enhancerList.add(jwtAccessTokenConverter);
        chain.setTokenEnhancers(enhancerList); // 将列表放入到 TokenEnhancerChain 中
        endpoints.tokenEnhancer(chain); // 端点配置,使 TokenEnhancerChain 生效
    }

Spring Security OAuth2 客户端

Spring Security OAuth2 客户端是用于代理我们对所谓的 OAuth2 授权服务器进行访问的工具。我们可以用其获得相应的 token ,且可以对资源进行进一步的请求。

常用类介绍

在这里插入图片描述

  • ClientRegistrationClientRegistration 是一个 OAuth2.0 或 OpenId Connect 1.0 的 Provider 注册的一个门面或代表对象。它包含了诸如 client-id,client-secret, authorization-grant-type,redirect-uri 等信息
  • ClientRegistrationRepositoryClientRegistrationRepository 用于存储和提供 ClientRegistration
  • OAuth2AuthorizedClientOAuth2AuthorizedClient 是授权客户端的表示形式。当终端用户(资源所有者)已向客户端授予访问其受保护资源的授权时,将客户端视为已授权。OAuth2AuthorizedClient 用于将 OAuth2AccessToken(和可选的 OAuth2RefreshToken)关联到 ClientRegistration(客户端)和资源所有者,后者是授予授权的主要终端用户。
  • OAuth2AuthorizedClientRepository/
    OAuth2AuthorizedClientService
    OAuth2AuthorizedClientRepository 负责在 Web 请求之间持久保存 OAuth2AuthorizedClient。。然而,OAuth2AuthorizedClientService 的主要角色是在应用程序级别管理 OAuth2AuthorizedClient。从开发人员的角度来看,OAuth2AuthorizedClientRepositoryOAuth2AuthorizedClientService 提供了查找与客户端关联的 OAuth2AccessToken 的功能,以便使用它来启动受保护的资源请求。
  • OAuth2AuthorizedClientManager / OAuth2AuthorizedClientProviderOAuth2AuthorizedClientManager 负责 OAuth2AuthorizedClient 的整体管理。Auth2AuthorizedClientProvider 实现了授权(或重新授权)OAuth 2.0 客户端的策略。实现通常会实现授权授予类型,例如。授权码、客户端凭据等。一般默认使用的 Auth2AuthorizedClientProviderDelegatingOAuth2AuthorizedClientProviderDelegatingOAuth2AuthorizedClientProvider 中包含了 List<OAuth2AuthorizedClientProvider> authorizedClientProviders

下图展示了 OAuth2AuthorizedClientProvider 的相关实现类
在这里插入图片描述

客户端请求 Token 的流程介绍

在这里插入图片描述

一言以蔽之就是一个 OAuth2AuthorizationRequest 通过
OAuth2AuthorizedClientManager 的验证,然后返回一个
OAuth2AuthorizedClient 的过程。其中 Token 存储在 OAuth2AuthorizedClient

在这里我们以 DefaultOAuth2AuthorizedClientManager 为示例进行介绍。

我们将其中的复杂部分去掉,其主要的流程如下图。

在这里插入图片描述

其中大多数操作均集中在转换阶段,下图是转换过程的流程图。

在这里插入图片描述

采用密码模式进行验证

密码模式的 provicer 的验证流程可如下图

在这里插入图片描述

配置 yaml

application.yml

server:
  port: 8088
auth_server: http://localhost:${server.port}/ # 指定授权服务器地址
spring:
  security:
    oauth2:
      client:
        registration:
          test: # registrationId
            clientId: client # clientId
            clientSecret: yourSecret # clientSecret
#            redirectUri: ${auth_server}/oauth/token
            authorizationGrantType: password # 授权类型
            scope: all # 授权范围
        provider:
          test: # providerId
#            authorizationUri: ${auth_server}/oauth/authorize # 验证授权的uri
            tokenUri: ${auth_server}/oauth/token # 获取 token 的 uri
#      resourceserver:
#        jwt:
#          jwk-set-uri:  ${auth_server}/oauth/token_key

编写使用客户端的配置类


@Configuration
public class OAuth2Config {
    @Bean
    public OAuth2AuthorizedClientManager authorizedClientManager(
            ClientRegistrationRepository clientRegistrationRepository,
            OAuth2AuthorizedClientRepository authorizedClientRepository) {
        OAuth2AuthorizedClientProvider authorizedClientProvider =  // 创建带有密码模式的 Provider
                OAuth2AuthorizedClientProviderBuilder.builder()
                        .password()
                        .build();
        DefaultOAuth2AuthorizedClientManager authorizedClientManager = // 创建 OAuth2AuthorizedClientManager 并曝露出去以供使用
                new DefaultOAuth2AuthorizedClientManager(
                        clientRegistrationRepository, authorizedClientRepository);
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider); // Manager 设置供验证使用的 Provider
        // 设定的 contextAttributesMapper
        // 用于将 OAuth2AuthorizationRequest 转换为  OAuth2AuthorizationContext 中的 attributes
        authorizedClientManager.setContextAttributesMapper(contextAttributesMapper());
        return authorizedClientManager;
    }
    private Function<OAuth2AuthorizeRequest, Map<String, Object>> contextAttributesMapper() { // 设定的 contextAttributesMapper
        return authorizeRequest -> { // OAuth2AuthorizationRequest
            Map<String, Object> contextAttributes = Collections.emptyMap();
            HttpServletRequest servletRequest = authorizeRequest.getAttribute(HttpServletRequest.class.getName());
            String username = servletRequest.getParameter(OAuth2ParameterNames.USERNAME);
            String password = servletRequest.getParameter(OAuth2ParameterNames.PASSWORD);
            if (StringUtils.hasText(username) && StringUtils.hasText(password)) {
                contextAttributes = new HashMap<>();
                // 设定用于 Oauth2 的用户名和密码
                contextAttributes.put(OAuth2AuthorizationContext.USERNAME_ATTRIBUTE_NAME, username);
                contextAttributes.put(OAuth2AuthorizationContext.PASSWORD_ATTRIBUTE_NAME, password);
            }
            return contextAttributes;
        };
    }
}

修改 Adapter

WebSecurityConfigurerAdapter

    // Step2: 重写 configure 方法
    @Override
    public void configure(HttpSecurity http) throws Exception{
        http.formLogin();
        http.authorizeRequests()
                .antMatchers("/oauth/**").permitAll() // 通过所有 OAuth2 请求
                .antMatchers(HttpMethod.POST,"/login").permitAll() // 通过 login 请求
                .antMatchers(HttpMethod.POST,"/test/**").permitAll() // 使用于测试的 url 进行用过
                .anyRequest().authenticated();
        http.logout().permitAll();
        http.csrf().disable();
        http.oauth2Client(Customizer.withDefaults()); // 启用 oauth2Client
    }

添加用于测试的 Controller

    @Autowired
    private OAuth2AuthorizedClientManager authorizedClientManager; // 注入 OAuth2AuthorizedClientManager
    @PostMapping("/test/1")
    public String index(Authentication authentication,
                        HttpServletRequest servletRequest,
                        HttpServletResponse servletResponse) {
        if(authentication == null)
             authentication = new UsernamePasswordAuthenticationToken("user","kgtdata");
        // 根据参数构建 OAuth2 请求对象
        OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId("test")
                .principal(authentication)
                .attributes(attrs -> {
                    attrs.put(HttpServletRequest.class.getName(), servletRequest);
                    attrs.put(HttpServletResponse.class.getName(), servletResponse);
                })
                .build();
        // 通过 Manager 验证然后返回相关对象
        OAuth2AuthorizedClient authorizedClient = this.authorizedClientManager.authorize(authorizeRequest);
        // 获取返回的 Token
        OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
        // 打印 Token
        System.out.println(accessToken.getTokenValue());
        return "index";
    }

测试效果

  • 使用 Postman 直接调用
    在这里插入图片描述

然后可以看到控制台打印出的 Token

采用授权码模式获取 Token

授权码获取 Token 的过程可如下图

在这里插入图片描述

变写 yaml 配置文件

server:
  port: 8088
auth_server: http://localhost:${server.port}/ # 指定授权服务器地址
spring:
  security:
    oauth2:
      client:
        registration:
          test: # registrationId
            clientId: client # clientId
            clientSecret: yourSecret # clientSecret
            redirectUri: ${auth_server}/test/2
            authorizationGrantType: authorization_code #password # 授权类型
            scope: all # 授权范围
        provider:
          test: # providerId
            authorizationUri: ${auth_server}/oauth/authorize # 验证授权的uri
            tokenUri: ${auth_server}/oauth/token # 获取 token 的 uri
#      resourceserver:
#        jwt:
#          jwk-set-uri:  ${auth_server}/oauth/token_key

编写重定向后获取 Token 的Controller

    @Autowired
    ClientRegistrationRepository clientRegistrationRepository;
    @GetMapping("test/2")
    public String authCode(@PathParam("code") String code){ // 授权码模式
        if(code != null && code != ""){
            OAuth2AuthorizationRequest oAuth2AuthorizeRequest = OAuth2AuthorizationRequest.authorizationCode()
                    .authorizationUri("http://localhost:8088/oauth/authorize")
                    .clientId("client")
                    .build();
            OAuth2AuthorizationResponse response = OAuth2AuthorizationResponse.success(code).
                    redirectUri("http://localhost:8088/test/2").build();
            OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange(oAuth2AuthorizeRequest,response);
            OAuth2AuthorizationCodeGrantRequest request = new OAuth2AuthorizationCodeGrantRequest(
                    clientRegistrationRepository.findByRegistrationId("test"),exchange);
            OAuth2AccessTokenResponseClient codeClient = new DefaultAuthorizationCodeTokenResponseClient();
            OAuth2AccessTokenResponse oauth2response = codeClient.getTokenResponse(request);
            OAuth2AccessToken s = oauth2response.getAccessToken();
            System.out.println(s.getTokenValue());
            return "test Success";
        }
        return "test Failure";
    }

测试效果

然后可以看到浏览器中 url 发生跳转,然后看到控制台打印出了 token

OAuth2 资源服务器

OAuth2 资源服务器的验证流程

img

  1. Filter来自读取承载令牌的身份验证将 BearerTokenAuthenticationToken 传递给 AuthenticationManager 由实现的 ProviderManager
  2. 配置 ProviderManagerAuthenticationProvider 类型 JwtAuthenticationProvider
  3. JwtAuthenticationProvider 使用 JwtDecoder 对 Jwt 解码、校验和验证有消性。
  4. 然后 JwtAuthenticationProvider 使用 JwtAuthenticationConverter 将 Jwt 转换为授予权限的 Collection
  5. 身份验证成功后,Authentication 返回的是 JwtAuthenticationToken 类型的对象,并且 Authentication
    principal 是由 JwtDecoder 返回的 Jwt 对象。最终,返回的 JwtAuthenticationToken 将由 SecurityContextHolderauthentication 中验证的 Filter 进行进一步的处理。

JwtAuthenticationProvider 的结构可简化成如下

在这里插入图片描述

JwtAuthenticationProvider 的具体验证流程如下

在这里插入图片描述

验证服务器端进行修改

Tips: 非对称加密
非对称加密算法需要两个密钥:公开密钥(publickey)和私有密钥(privatekey)。公开密钥与私有密钥是一对,如果用公开密钥对数据进行加密,只有用对应的私有密钥才能解密;如果用私有密钥对数据进行加密,那么只有用对应的公开密钥才能解密。因为加密和解密使用的是两个不同的密钥,所以这种算法叫作非对称加密算法。

验证服务器单主要使用私钥对 Jwt 进行加密,然后使用公钥对数据进行解密。因此私钥在验证服务器端,而公钥则在客户端。首先我们先在验证服务器上引入私钥进行使用。

首先使用 Keytool 创建私钥

keytool -genkey \
    -alias jwt \ # 设置别名
    -keyalg RSA \ # 设置算法
    -keysize 1024 \
    -keystore jwt.jks \ # 设置秘钥的存储名称
    -validity 365 \ # 设置私钥的有效日期
    -keypass 1234556 -storepass 123456 # 设置密码

在验证服务器上使用私钥

将秘钥文件放在 resources 文件夹下
在这里插入图片描述

修改 JwtAccessTokenConverter

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter(){
        KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"),"123456".toCharArray()); // 123456 为生成秘钥时的密码
        JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
        accessTokenConverter.setKeyPair(keyStoreKeyFactory.getKeyPair("jwt"));
        return accessTokenConverter;
    }

使用 OAuth2 资源服务器

通过私钥生成公钥

  • 方式一: 通过 openssl 获取公钥
keytool -list -rfc --keystore jwt.jks |  openssl x509 -inform pem -pubkey

将获得的公钥存储在 public.cert 文件中

在服务器上使用私钥

将 public.cert 文件放在

在这里插入图片描述

修改 yml 配置文件

application.yml

spring:
  security:
    oauth2:
      client:
        registration:
          test: # registrationId
            clientId: client # clientId
            clientSecret: yourSecret # clientSecret
            redirectUri: http://localhost:${server.port}/test/2
            authorizationGrantType:  password # authorization_code # 授权类型
            scope: all # 授权范围
        provider:
          test: # providerId
            authorizationUri: ${auth_server}/oauth/authorize # 验证授权的uri
            tokenUri: ${auth_server}/oauth/token # 获取 token 的 uri
      resourceserver:
        jwt:
          public-key-location: classpath:public.cert  # 指定公钥位置

修改 WebSecurityConfigurerAdapter

WebSecurityConfigurerAdapter

    @Override
    public void configure(HttpSecurity http) throws Exception{
        http.csrf().disable();
        http.authorizeRequests()
                .antMatchers("/test/**").permitAll()
                .antMatchers("/user/**").permitAll()
                .anyRequest().authenticated();
        http.oauth2Client(Customizer.withDefaults());
        http.oauth2ResourceServer().jwt(); // 启用 oauth2 resource server
        http.formLogin();
    }

对 Controller 进行修改

    @Autowired
    JwtDecoder jwtDecoder;
   
    @PostMapping("/test/1")
    public String index(Authentication authentication,
                        HttpServletRequest servletRequest,
                        HttpServletResponse servletResponse) {
        if(authentication == null)
            authentication = new UsernamePasswordAuthenticationToken("user","password");
        // 根据参数构建 OAuth2 请求对象
        OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId("test")
                .principal(authentication)
                .attributes(attrs -> {
                    attrs.put(HttpServletRequest.class.getName(), servletRequest);
                    attrs.put(HttpServletResponse.class.getName(), servletResponse);
                })
                .build();
        // 通过 Manager 验证然后返回相关对象
        OAuth2AuthorizedClient authorizedClient = this.authorizedClientManager.authorize(authorizeRequest);
        // 获取返回的 Token
        OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
        // 打印 Token
        System.out.println(accessToken.getTokenValue());
        Jwt jwt = jwtDecoder.decode(accessToken.getTokenValue()); // 使用 JwtDecoder 进行解析
        System.out.println(jwt.getHeaders());
        System.out.println(jwt.getId());
        return "index";
    }
  Java知识库 最新文章
计算距离春节还有多长时间
系统开发系列 之WebService(spring框架+ma
springBoot+Cache(自定义有效时间配置)
SpringBoot整合mybatis实现增删改查、分页查
spring教程
SpringBoot+Vue实现美食交流网站的设计与实
虚拟机内存结构以及虚拟机中销毁和新建对象
SpringMVC---原理
小李同学: Java如何按多个字段分组
打印票据--java
上一篇文章      下一篇文章      查看所有文章
加:2022-06-23 00:50:03  更:2022-06-23 00:50:38 
 
开发: 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/23 16:46:56-

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