Spring Boot 接入 GitHub 第三方登录

osc_rebyzmjz 2020-11-10 13:38:55
oauth


点击上方蓝字设为星标


链接:zyc.red/Spring/Security/OAuth2/OAuth2-Client/

前言

OAuth(开放授权)是一个开放标准,允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们数据的所有内容。网上有很多关于OAuth协议的讲解,这里就不在详细解释OAuth相关的概念了,请读者自行查阅相关资料,否则本文接下来的内容可能会很难理解。

Spring-Security对OAuth2.0的支持

截止到本文撰写的日期为止,Spring已经提供了对OAuth提供的支持(spring-security-oauth:https://github.com/spring-projects/spring-security-oauth),但是该工程已经被废弃了,因为Spring-Security工程提供了最新的OAuth2.0支持。如果你的项目中使用了过期的Spring-Security-OAuth,请参考《OAuth 2.0迁移指南:https://github.com/spring-projects/spring-security/wiki/OAuth-2.0-Migration-Guide》,

本文将对OAuth2.0中的客户端模式进行原理分析,结合Spring官方指南中提供了一个简单的基于spring-boot与oauth2.0集成第三方应用登录的案例(spring-boot-oauth2:https://spring.io/guides/tutorials/spring-boot-oauth2/),一步一步分析其内部实现的原理。

公众号同样发布过近百篇 Spring Boot 相关的实战文章,关注微信公众号 Java后端,回复 666 下载这本技术栈手册。

创建GitHub OAuth Apps

在Github OAuth Apps中创建一个新的应用

这个应用相当于我们自己的应用(客户端),被注册在Github(授权服务器)中了,如果我们应用中的用户有github账号的话,则可以基于oauth2来登录我们的系统,替代原始的用户名密码方式。在官方指南的例子中,使用spring-security和oauth2进行社交登陆只需要在你的pom文件中加入以下几个依赖即可:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<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>

然后在配置文件中填上刚刚注册的应用的clientId和clientSecret:

spring:
  security:
    oauth2:
      client:
        registration:
          github:
            clientId: github-client-id
            clientSecret: github-client-secret

紧接着就像普通的spring-security应用一样,继承WebSecurityConfigurerAdapter,进行一些简单的配置即可:

@SpringBootApplication
@RestController
public class SocialApplication extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
      
        http
            .authorizeRequests(a -> a
                .antMatchers("/", "/error", "/webjars/**").permitAll()
                .anyRequest().authenticated()
            )
            .exceptionHandling(e -> e
                .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
            )
            .oauth2Login();
        
    }
}

也就是说我们只需要添加maven依赖以及继承WebSecurityConfigurerAdapter进行一些简单的配置,一个oauth2客户端应用就构建完成了。接下来按照指南上的步骤点击页面的github登录链接我们的页面就会跳转到github授权登录页,等待用户授权完成之后浏览器重定向到我们的callback URL最终请求user信息端点即可访问到刚刚登入的github用户信息,整个应用的构建是如此的简单,背后的原理是什么呢?接下来我们开始分析。

还是和以前一样,我们在配置文件中将security的日志级别设置为debug

logging:
  level:
    org.springframework.security: debug

重新启动应用之后,从控制台输出中我们可以看到与普通spring-security应用不同的地方在于整个过滤链多出了以下几个过滤器:

OAuth2AuthorizationRequestRedirectFilter
OAuth2LoginAuthenticationFilter

联想oauth2的授权码模式以及这两个过滤器的名字,熟悉spring-security的同学心中肯定已经有了一点想法了。对没错,spring-security对客户端模式的支持完全就是基于这两个过滤器来实现的。现在我们来回想以下授权码模式的执行流程

  1. 用户在客户端页面点击三方应用登录按钮(客户端就是我们刚刚注册的github应用)

  2. 页面跳转到三方应用注册的授权方页面(授权服务器即github)

  3. 用户登入授权后,github调用我们应用的回调地址(我们刚刚注册github应用时填写的回调地址)

  4. 第三步的回调地址中github会将code参数放到url中,接下来我们的客户端就会在内部拿这个code再次去调用github

    的access_token地址获取令牌

上面就是标准的authorization_code授权模式,OAuth2AuthorizationRequestRedirectFilter的作用就是上面步骤中的1.2步的合体,当用户点击页面的github授权url之后,OAuth2AuthorizationRequestRedirectFilter匹配这个请求,接着它会将我们配置文件中的clientId、scope以及构造一个state参数(防止csrf攻击)拼接成一个url重定向到github的授权url,OAuth2LoginAuthenticationFilter的作用则是上面3.4步骤的合体,当用户在github的授权页面授权之后github调用回调地址,OAuth2LoginAuthenticationFilter匹配这个回调地址,解析回调地址后的code与state参数进行验证之后内部拿着这个code远程调用github的access_token地址,拿到access_token之后通过OAuth2UserService获取相应的用户信息(内部是拿access_token远程调用github的用户信息端点)最后将用户信息构造成Authentication被SecurityContextPersistenceFilter过滤器保存到HttpSession中。

下面我们就来看一下这两个过滤器内部执行的原理:

public class OAuth2AuthorizationRequestRedirectFilter extends OncePerRequestFilter {
    
    ......省略部分代码
  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {
    try {
      OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request);
      if (authorizationRequest != null) {
        this.sendRedirectForAuthorization(request, response, authorizationRequest);
        return;
      }
    } catch (Exception failed) {
      this.unsuccessfulRedirectForAuthorization(request, response, failed);
      return;
    }
        ......省略部分代码
}

通过authorizationRequestResolver解析器解析请求,解析器的默认实现是DefaultOAuth2AuthorizationRequestResolver,核心解析方法如下:

@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
    
    String registrationId = this.resolveRegistrationId(request);
    String redirectUriAction = getAction(request, "login");
    return resolve(request, registrationId, redirectUriAction);
}
private OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId, String redirectUriAction) {
    if (registrationId == null) {
        return null;
    }
  
    ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
    if (clientRegistration == null) {
        throw new IllegalArgumentException("Invalid Client Registration with Id: " + registrationId);
    }
    Map<String, Object> attributes = new HashMap<>();
    attributes.put(OAuth2ParameterNames.REGISTRATION_ID, clientRegistration.getRegistrationId());
    OAuth2AuthorizationRequest.Builder builder;
    
    if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(clientRegistration.getAuthorizationGrantType())) {
        builder = OAuth2AuthorizationRequest.authorizationCode();
        Map<String, Object> additionalParameters = new HashMap<>();
        if (!CollectionUtils.isEmpty(clientRegistration.getScopes()) &&
            clientRegistration.getScopes().contains(OidcScopes.OPENID)) {
            
            
            
            addNonceParameters(attributes, additionalParameters);
        }
        if (ClientAuthenticationMethod.NONE.equals(clientRegistration.getClientAuthenticationMethod())) {
            addPkceParameters(attributes, additionalParameters);
        }
        builder.additionalParameters(additionalParameters);
    } else if (AuthorizationGrantType.IMPLICIT.equals(clientRegistration.getAuthorizationGrantType())) {
        builder = OAuth2AuthorizationRequest.implicit();
    } else {
        throw new IllegalArgumentException("Invalid Authorization Grant Type ("  +
                                           clientRegistration.getAuthorizationGrantType().getValue() +
                                           ") for Client Registration with Id: " + clientRegistration.getRegistrationId());
    }
    String redirectUriStr = expandRedirectUri(request, clientRegistration, redirectUriAction);
    OAuth2AuthorizationRequest authorizationRequest = builder
        .clientId(clientRegistration.getClientId())
        .authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri())
        .redirectUri(redirectUriStr)
        .scopes(clientRegistration.getScopes())
        
        .state(this.stateGenerator.generateKey())
        .attributes(attributes)
        .build();
    return authorizationRequest;
}

DefaultOAuth2AuthorizationRequestResolver判断请求是否是授权请求,最终返回一个OAuth2AuthorizationRequest对象给OAuth2AuthorizationRequestRedirectFilter,如果OAuth2AuthorizationRequest不为null的话,说明当前请求是一个授权请求,那么接下来就要拿着这个请求重定向到授权服务器的授权端点了,下面我们接着看OAuth2AuthorizationRequestRedirectFilter发送重定向的逻辑:

private void sendRedirectForAuthorization(HttpServletRequest request, HttpServletResponse response,
                                          OAuth2AuthorizationRequest authorizationRequest) throws IOException {
    if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(authorizationRequest.getGrantType())) {
        this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response);
    }
    this.authorizationRedirectStrategy.sendRedirect(request, response, authorizationRequest.getAuthorizationRequestUri());
}

1. 如果当前是授权码类型的授权请求那么就需要将这个请求信息保存下来,因为接下来授权服务器回调我们需要用到这个授权请求的参数进行校验等操作(比对state),这里是通过authorizationRequestRepository保存授权请求的,默认的保存方式是通过HttpSessionOAuth2AuthorizationRequestRepository保存在httpsession中的,具体的保存逻辑很简单,这里就不细说了。

2. 保存完成之后就要开始重定向到授权服务端点了,这里默认的authorizationRedirectStrategy是DefaultRedirectStrategy,重定向的逻辑很简单,通过response.sendRedirect方法使前端页面重定向到指定的授权

public void sendRedirect(HttpServletRequest request, HttpServletResponse response,
                         String url) throws IOException {
    String redirectUrl = calculateRedirectUrl(request.getContextPath(), url);
    redirectUrl = response.encodeRedirectURL(redirectUrl);
    if (logger.isDebugEnabled()) {
        logger.debug("Redirecting to '" + redirectUrl + "'");
    }
    response.sendRedirect(redirectUrl);
}

OAuth2AuthorizationRequestRedirectFilter处理逻辑讲完了,下面我们对它处理过程做一个总结

a. 通过内部的OAuth2AuthorizationRequestResolver解析当前的请求,返回一个OAuth2AuthorizationRequest对象,如果当前请求是授权端点请求,那么就会返回一个构造好的对象,包含我们的client_id、state、redirect_uri参数,如果对象为null的话,那么就说明当前请求不是授权端点请求。

注意如果OAuth2AuthorizationRequestResolver不为null的话,OAuth2AuthorizationRequestResolver内部会将其保存在httpsession中这样授权服务器在调用我们的回调地址时我们就能从httpsession中取出请求将state进行对比以防csrf攻击。

b. 如果第一步返回的OAuth2AuthorizationRequest对象不为null的话,接下来就会通过response.sendRedirect的方法将OAuth2AuthorizationRequest中的授权端点请求发送到前端的响应头中然后浏览器就会重定向到授权页面,等待用户授权。

OAuth2LoginAuthenticationFilter

public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    @Override
  public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
      throws AuthenticationException {
    MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());
        
    if (!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params)) {
      OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST);
      throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
    }
    
        
    OAuth2AuthorizationRequest authorizationRequest =
        this.authorizationRequestRepository.removeAuthorizationRequest(request, response);
    if (authorizationRequest == null) {
      OAuth2Error oauth2Error = new OAuth2Error(AUTHORIZATION_REQUEST_NOT_FOUND_ERROR_CODE);
      throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
    }
    
        
    String registrationId = authorizationRequest.getAttribute(OAuth2ParameterNames.REGISTRATION_ID);
    ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
    if (clientRegistration == null) {
      OAuth2Error oauth2Error = new OAuth2Error(CLIENT_REGISTRATION_NOT_FOUND_ERROR_CODE,
          "Client Registration not found with Id: " + registrationId, null);
      throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
    }
    String redirectUri = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
        .replaceQuery(null)
        .build()
        .toUriString();
    OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params, redirectUri);
    Object authenticationDetails = this.authenticationDetailsSource.buildDetails(request);
    OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(
        clientRegistration, new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse));
    authenticationRequest.setDetails(authenticationDetails);
    
    OAuth2LoginAuthenticationToken authenticationResult =
      (OAuth2LoginAuthenticationToken) this.getAuthenticationManager().authenticate(authenticationRequest);
    
    OAuth2AuthenticationToken oauth2Authentication = new OAuth2AuthenticationToken(
      authenticationResult.getPrincipal(),
      authenticationResult.getAuthorities(),
      authenticationResult.getClientRegistration().getRegistrationId());
    oauth2Authentication.setDetails(authenticationDetails);  
        
    OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(
      authenticationResult.getClientRegistration(),
      oauth2Authentication.getName(),
      authenticationResult.getAccessToken(),
      authenticationResult.getRefreshToken());
    this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response);
    return oauth2Authentication;
  }
}

OAuth2LoginAuthenticationFilter的作用很简单,就是响应授权服务器的回调地址,核心之处在于OAuth2LoginAuthenticationProvider对OAuth2LoginAuthenticationToken的认证,

OAuth2LoginAuthenticationToken

OAuth2LoginAuthenticationProvider

public class OAuth2LoginAuthenticationProvider implements AuthenticationProvider {
    
     ...省略部分代码
    
    @Override
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    OAuth2LoginAuthenticationToken authorizationCodeAuthentication =
      (OAuth2LoginAuthenticationToken) authentication;
    
    
    
    if (authorizationCodeAuthentication.getAuthorizationExchange()
      .getAuthorizationRequest().getScopes().contains("openid")) {
      
      
      return null;
    }
    OAuth2AccessTokenResponse accessTokenResponse;
    try {
      OAuth2AuthorizationExchangeValidator.validate(
          authorizationCodeAuthentication.getAuthorizationExchange());
      
      accessTokenResponse = this.accessTokenResponseClient.getTokenResponse(
          new OAuth2AuthorizationCodeGrantRequest(
              authorizationCodeAuthentication.getClientRegistration(),
              authorizationCodeAuthentication.getAuthorizationExchange()));
    } catch (OAuth2AuthorizationException ex) {
      OAuth2Error oauth2Error = ex.getError();
      throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
    }
    
         
    OAuth2AccessToken accessToken = accessTokenResponse.getAccessToken();
    Map<String, Object> additionalParameters = accessTokenResponse.getAdditionalParameters();
    
         
    OAuth2User oauth2User = this.userService.loadUser(new OAuth2UserRequest(
        authorizationCodeAuthentication.getClientRegistration(), accessToken, additionalParameters));
    Collection<? extends GrantedAuthority> mappedAuthorities =
      this.authoritiesMapper.mapAuthorities(oauth2User.getAuthorities());
    
         
    OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(
      authorizationCodeAuthentication.getClientRegistration(),
      authorizationCodeAuthentication.getAuthorizationExchange(),
      oauth2User,
      mappedAuthorities,
      accessToken,
      accessTokenResponse.getRefreshToken());
    authenticationResult.setDetails(authorizationCodeAuthentication.getDetails());
    return authenticationResult;
  }
    ...省略部分代码
}

OAuth2LoginAuthenticationProvider的执行逻辑很简单,首先通过code获取access_token,然后通过access_token获取用户信息,这和标准的oauth2授权码模式一致。

自动配置

在spring指南的例子中,我们发现只是配置了一个简单oauth2Login()方法,一个完整的oauth2授权流程就构建好了,其实这完全归功于spring-boot的autoconfigure,我们找到spring-boot-autoconfigure.jar包中的security.oauth2.client.servlet包,可以发现spring-boot给我们提供了几个自动配置类:

OAuth2ClientAutoConfiguration
OAuth2ClientRegistrationRepositoryConfiguration
OAuth2WebSecurityConfiguration

其中OAuth2ClientAutoConfiguration导入了OAuth2ClientRegistrationRepositoryConfiguration和OAuth2WebSecurityConfiguration的配置

OAuth2ClientRegistrationRepositoryConfiguration:

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(OAuth2ClientProperties.class)
@Conditional(ClientsConfiguredCondition.class)
class OAuth2ClientRegistrationRepositoryConfiguration {
    @Bean
    @ConditionalOnMissingBean(ClientRegistrationRepository.class)
    InMemoryClientRegistrationRepository clientRegistrationRepository(OAuth2ClientProperties properties) {
        List<ClientRegistration> registrations = new ArrayList<>(
            OAuth2ClientPropertiesRegistrationAdapter.getClientRegistrations(properties).values());
        return new InMemoryClientRegistrationRepository(registrations);
    }
}

OAuth2ClientRegistrationRepositoryConfiguration将我们在配置文件中注册的client构造成ClientRegistration然后保存到内存之中。这里有一个隐藏的CommonOAuth2Provider类,这是一个枚举类,里面事先定义好了几种常用的三方登录授权服务器的各种参数例如GOOGLE、GITHUB、FACEBOO、OKTA

CommonOAuth2Provider

public enum CommonOAuth2Provider {
  GOOGLE {
    @Override
    public Builder getBuilder(String registrationId) {
      ClientRegistration.Builder builder = getBuilder(registrationId,
          ClientAuthenticationMethod.BASIC, DEFAULT_REDIRECT_URL);
      builder.scope("openid", "profile", "email");
      builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
      builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
      builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
      builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo");
      builder.userNameAttributeName(IdTokenClaimNames.SUB);
      builder.clientName("Google");
      return builder;
    }
  },
  GITHUB {
    @Override
    public Builder getBuilder(String registrationId) {
      ClientRegistration.Builder builder = getBuilder(registrationId,
          ClientAuthenticationMethod.BASIC, DEFAULT_REDIRECT_URL);
      builder.scope("read:user");
      builder.authorizationUri("https://github.com/login/oauth/authorize");
      builder.tokenUri("https://github.com/login/oauth/access_token");
      builder.userInfoUri("https://api.github.com/user");
      builder.userNameAttributeName("id");
      builder.clientName("GitHub");
      return builder;
    }
  },
  FACEBOOK {
    @Override
    public Builder getBuilder(String registrationId) {
      ClientRegistration.Builder builder = getBuilder(registrationId,
          ClientAuthenticationMethod.POST, DEFAULT_REDIRECT_URL);
      builder.scope("public_profile", "email");
      builder.authorizationUri("https://www.facebook.com/v2.8/dialog/oauth");
      builder.tokenUri("https://graph.facebook.com/v2.8/oauth/access_token");
      builder.userInfoUri("https://graph.facebook.com/me?fields=id,name,email");
      builder.userNameAttributeName("id");
      builder.clientName("Facebook");
      return builder;
    }
  },
  OKTA {
    @Override
    public Builder getBuilder(String registrationId) {
      ClientRegistration.Builder builder = getBuilder(registrationId,
          ClientAuthenticationMethod.BASIC, DEFAULT_REDIRECT_URL);
      builder.scope("openid", "profile", "email");
      builder.userNameAttributeName(IdTokenClaimNames.SUB);
      builder.clientName("Okta");
      return builder;
    }
  };
  private static final String DEFAULT_REDIRECT_URL = "{baseUrl}/{action}/oauth2/code/{registrationId}";
  protected final ClientRegistration.Builder getBuilder(String registrationId,
                              ClientAuthenticationMethod method, String redirectUri) {
    ClientRegistration.Builder builder = ClientRegistration.withRegistrationId(registrationId);
    builder.clientAuthenticationMethod(method);
    builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE);
    builder.redirectUriTemplate(redirectUri);
    return builder;
  }
    
  public abstract ClientRegistration.Builder getBuilder(String registrationId);
}

这就是为什么我们没有配置github授权端点确能够跳转授权页面的原因。

OAuth2WebSecurityConfiguration

OAuth2WebSecurityConfiguration配置一些web相关的类,像如何去保存和获取已经授权过的客户端,以及默认的oauth2客户端相关的配置

@Configuration(proxyBeanMethods = false)
@ConditionalOnBean(ClientRegistrationRepository.class)
class OAuth2WebSecurityConfiguration {
  @Bean
  @ConditionalOnMissingBean
  OAuth2AuthorizedClientService authorizedClientService(ClientRegistrationRepository clientRegistrationRepository) {
    return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository);
  }
  @Bean
  @ConditionalOnMissingBean
  OAuth2AuthorizedClientRepository authorizedClientRepository(OAuth2AuthorizedClientService authorizedClientService) {
    return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService);
  }
  
    
  @Configuration(proxyBeanMethods = false)
  @ConditionalOnMissingBean(WebSecurityConfigurerAdapter.class)
  static class OAuth2WebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
      http.authorizeRequests((requests) -> requests.anyRequest().authenticated());
      http.oauth2Login(Customizer.withDefaults());
      http.oauth2Client();
    }
  }
}

参考:

集成GitHub和QQ社交登录

https://github.com/Allurx/spring-security-oauth2-demo/tree/master/spring-security-oauth2-client

spring-security-oauth更新路线

https://spring.io/blog/2019/11/14/spring-security-oauth-2-0-roadmap-update

spring-security对oauth2.0授权服务器的支持

https://github.com/spring-projects/spring-security/issues/6320

使用spring-boot和oauth2.0构建社交登陆

https://spring.io/guides/tutorials/spring-boot-oauth2/

推荐阅读
代码对比工具,我就用这6个
分享我常用的5个免费的在线 SQL 数据库环境,简直太方便了!
Spring Boot 三招组合拳,手把手教你打出优雅的后端接口
MySQL 5.7 vs 8.0,你选那个?网友:我继续原地踏步~

最后,推荐给大家一个有趣有料的公众号:写代码的渣渣鹏,7年老程序员教你写bug,回复 面试|资源 送一你整套开发笔记 有惊喜哦
版权声明
本文为[osc_rebyzmjz]所创,转载请带上原文链接,感谢
https://my.oschina.net/u/4385225/blog/4711144

  1. 【计算机网络 12(1),尚学堂马士兵Java视频教程
  2. 【程序猿历程,史上最全的Java面试题集锦在这里
  3. 【程序猿历程(1),Javaweb视频教程百度云
  4. Notes on MySQL 45 lectures (1-7)
  5. [computer network 12 (1), Shang Xuetang Ma soldier java video tutorial
  6. The most complete collection of Java interview questions in history is here
  7. [process of program ape (1), JavaWeb video tutorial, baidu cloud
  8. Notes on MySQL 45 lectures (1-7)
  9. 精进 Spring Boot 03:Spring Boot 的配置文件和配置管理,以及用三种方式读取配置文件
  10. Refined spring boot 03: spring boot configuration files and configuration management, and reading configuration files in three ways
  11. 精进 Spring Boot 03:Spring Boot 的配置文件和配置管理,以及用三种方式读取配置文件
  12. Refined spring boot 03: spring boot configuration files and configuration management, and reading configuration files in three ways
  13. 【递归,Java传智播客笔记
  14. [recursion, Java intelligence podcast notes
  15. [adhere to painting for 386 days] the beginning of spring of 24 solar terms
  16. K8S系列第八篇(Service、EndPoints以及高可用kubeadm部署)
  17. K8s Series Part 8 (service, endpoints and high availability kubeadm deployment)
  18. 【重识 HTML (3),350道Java面试真题分享
  19. 【重识 HTML (2),Java并发编程必会的多线程你竟然还不会
  20. 【重识 HTML (1),二本Java小菜鸟4面字节跳动被秒成渣渣
  21. [re recognize HTML (3) and share 350 real Java interview questions
  22. [re recognize HTML (2). Multithreading is a must for Java Concurrent Programming. How dare you not
  23. [re recognize HTML (1), two Java rookies' 4-sided bytes beat and become slag in seconds
  24. 造轮子系列之RPC 1:如何从零开始开发RPC框架
  25. RPC 1: how to develop RPC framework from scratch
  26. 造轮子系列之RPC 1:如何从零开始开发RPC框架
  27. RPC 1: how to develop RPC framework from scratch
  28. 一次性捋清楚吧,对乱糟糟的,Spring事务扩展机制
  29. 一文彻底弄懂如何选择抽象类还是接口,连续四年百度Java岗必问面试题
  30. Redis常用命令
  31. 一双拖鞋引发的血案,狂神说Java系列笔记
  32. 一、mysql基础安装
  33. 一位程序员的独白:尽管我一生坎坷,Java框架面试基础
  34. Clear it all at once. For the messy, spring transaction extension mechanism
  35. A thorough understanding of how to choose abstract classes or interfaces, baidu Java post must ask interview questions for four consecutive years
  36. Redis common commands
  37. A pair of slippers triggered the murder, crazy God said java series notes
  38. 1、 MySQL basic installation
  39. Monologue of a programmer: despite my ups and downs in my life, Java framework is the foundation of interview
  40. 【大厂面试】三面三问Spring循环依赖,请一定要把这篇看完(建议收藏)
  41. 一线互联网企业中,springboot入门项目
  42. 一篇文带你入门SSM框架Spring开发,帮你快速拿Offer
  43. 【面试资料】Java全集、微服务、大数据、数据结构与算法、机器学习知识最全总结,283页pdf
  44. 【leetcode刷题】24.数组中重复的数字——Java版
  45. 【leetcode刷题】23.对称二叉树——Java版
  46. 【leetcode刷题】22.二叉树的中序遍历——Java版
  47. 【leetcode刷题】21.三数之和——Java版
  48. 【leetcode刷题】20.最长回文子串——Java版
  49. 【leetcode刷题】19.回文链表——Java版
  50. 【leetcode刷题】18.反转链表——Java版
  51. 【leetcode刷题】17.相交链表——Java&python版
  52. 【leetcode刷题】16.环形链表——Java版
  53. 【leetcode刷题】15.汉明距离——Java版
  54. 【leetcode刷题】14.找到所有数组中消失的数字——Java版
  55. 【leetcode刷题】13.比特位计数——Java版
  56. oracle控制用户权限命令
  57. 三年Java开发,继阿里,鲁班二期Java架构师
  58. Oracle必须要启动的服务
  59. 万字长文!深入剖析HashMap,Java基础笔试题大全带答案
  60. 一问Kafka就心慌?我却凭着这份,图灵学院vip课程百度云