如何使用 JDBC 实现水平扩展 spring-boot oauth2 服务器

How to scale horizontally a spring-boot oauth2 server with JDBC implementation

我有一个 spring 启动 oauth2 服务器,它使用 JDBC 实现。它被配置为带有@EnableAuthorizationServer 的授权服务器。

我想水平缩放该应用程序,但它似乎无法正常工作。

我只有一个服务器实例(pods)才能连接。

我使用来自另一个客户端服务的 autorisation_code_client 授权来获取令牌。 因此,首先客户端服务将用户重定向到 oauth2 服务器表单,然后一旦用户通过身份验证,他应该被重定向到带有附加到 url 的代码的客户端服务,最后客户端使用该代码来再次请求oauth2服务器,获取token。

这里,如果我有多个 oauth2-server 实例,用户根本不会被重定向。有一个实例效果很好。

当我实时查看两个实例的日志时,我可以看到认证在其中一个上起作用。我没有任何特定错误,用户只是没有被重定向。

有没有办法将 oauth2-server 配置为无状态或其他方式来解决该问题?

这是我的配置,AuthorizationServerConfigurerAdapter 实现。

@Configuration
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {


    @Bean
    @ConfigurationProperties(prefix = "spring.datasource")
    public DataSource oauthDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Autowired
    @Qualifier("authenticationManagerBean")
    private AuthenticationManager authenticationManager;

    @Bean
    public JdbcClientDetailsService clientDetailsSrv() {
        return new JdbcClientDetailsService(oauthDataSource());
    }

    @Bean
    public TokenStore tokenStore() {
        return new JdbcTokenStore(oauthDataSource());
    }

    @Bean
    public ApprovalStore approvalStore() {
        return new JdbcApprovalStore(oauthDataSource());
    }

    @Bean
    public AuthorizationCodeServices authorizationCodeServices() {
        return new JdbcAuthorizationCodeServices(oauthDataSource());
    }

    @Bean
    public TokenEnhancer tokenEnhancer() {

        return new CustomTokenEnhancer();
    }

    @Bean
    @Primary
    public AuthorizationServerTokenServices tokenServices() {


        DefaultTokenServices tokenServices = new DefaultTokenServices();

        tokenServices.setTokenStore(tokenStore());

        tokenServices.setTokenEnhancer(tokenEnhancer());

        return tokenServices;
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {

        clients.withClientDetails(clientDetailsSrv());
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer oauthServer)  {

        oauthServer
                .tokenKeyAccess("permitAll()")
                .checkTokenAccess("isAuthenticated()")
                .allowFormAuthenticationForClients();

    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints)  {
        endpoints
                .authenticationManager(authenticationManager)
                .approvalStore(approvalStore())
                //.approvalStoreDisabled()
                .authorizationCodeServices(authorizationCodeServices())
                .tokenStore(tokenStore())
                .tokenEnhancer(tokenEnhancer());
    }

}

主要class

@SpringBootApplication
@EnableResourceServer
@EnableAuthorizationServer
@EnableConfigurationProperties
@EnableFeignClients("com.oauth2.proxies")
public class AuthorizationServerApplication {


    public static void main(String[] args) {

        SpringApplication.run(AuthorizationServerApplication.class, args);

    }

}

网络安全配置

@Configuration
@Order(1)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {


    @Bean
    @Override
    public UserDetailsService userDetailsServiceBean() throws Exception {
        return new JdbcUserDetails();
    }

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception { // @formatter:off

        http.requestMatchers()
                .antMatchers("/",
                        "/login",
                        "/login.do",
                        "/registration",
                        "/registration/confirm/**",
                        "/registration/resendToken",
                        "/password/forgot",
                        "/password/change",
                        "/password/change/**",
                        "/oauth/authorize**")
                .and()
                .authorizeRequests()//autorise les requetes
                .antMatchers(
                        "/",
                        "/login",
                        "/login.do",
                        "/registration",
                        "/registration/confirm/**",
                        "/registration/resendToken",
                        "/password/forgot",
                        "/password/change",
                        "/password/change/**")
                .permitAll()
                .and()
                .requiresChannel()
                .anyRequest()
                .requiresSecure()
                .and()
                .authorizeRequests()
                .anyRequest()
                .authenticated()
                .and()
                .formLogin()
                .loginPage("/login")
                .loginProcessingUrl("/login.do")
                .usernameParameter("username")
                .passwordParameter("password")
                .and()
                .userDetailsService(userDetailsServiceBean());


    } // @formatter:on


    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsServiceBean()).passwordEncoder(passwordEncoder());
    }


}

客户端 WebSecurityConfigurerAdapter

@EnableOAuth2Sso
@Configuration
public class UiSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {

        http.antMatcher("/**")
                .authorizeRequests()
                .antMatchers(
                        "/",
                        "/index.html",
                        "/login**",
                        "/logout**",
                        //resources
                        "/assets/**",
                        "/static/**",
                        "/*.ico",
                        "/*.js",
                        "/*.json").permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .csrf().csrfTokenRepository(csrfTokenRepository())
                .and()
                .addFilterAfter(csrfHeaderFilter(), SessionManagementFilter.class);
    }

}

oauth2 配置属性

oauth2-server 是 kubernetes 上的服务名称(负载均衡器),也是它出现两次的服务器路径。

security:
    oauth2:
        client:
            clientId: **********
            clientSecret: *******
            accessTokenUri: https://oauth2-server/oauth2-server/oauth/token
            userAuthorizationUri: https://oauth2.mydomain.com/oauth2-server/oauth/authorize
        resource:
            userInfoUri: https://oauth2-server/oauth2-server/me

这里有个重要的细节,userAuthorizationUri的值是k8s集群外部访问oauth2-server的地址。如果用户未连接并尝试访问客户端服务的 /login 路径,则客户端服务将该地址发送回带有 302 http 代码的响应中。然后用户被重定向到 oauth2-server 的 /login 路径。
https://oauth2.mydomain.com 以处理重定向到负载均衡器服务的 Nginx Ingress 控制器为目标。

默认使用内存中的 TokenStore

The default InMemoryTokenStore is perfectly fine for a single server

如果你想要多个 pods,你可能应该选择 JdbcTokenStore

The JdbcTokenStore is the JDBC version of the same thing, which stores token data in a relational database. Use the JDBC version if you can share a database between servers, either scaled up instances of the same server if there is only one, or the Authorization and Resources Servers if there are multiple components. To use the JdbcTokenStore you need "spring-jdbc" on the classpath.

来源Spring Security: OAuth 2 Developers Guide

这里有一个解决这个问题的方法。这根本不是 Spring 问题,而是 Nginx Ingress 控制器的错误配置。

身份验证过程分几个阶段完成:

1 - 用户点击以客户端-服务器的 /login 路径为目标的登录按钮

2 - 客户端-服务器,如果用户尚未通过身份验证,则向 浏览器使用 302 http 代码将用户重定向到 oauth2-server,值为 重定向由值组成 安全。oauth2.client.userAuthorizationUri 属性 以及浏览器将使用的重定向 url,以允许客户端服务器在用户通过身份验证后获取令牌。 url 看起来像这样:

h*tps://oauth2.mydomain.com/oauth2-server/oauth/authorize?client_id=autorisation_code_client&redirect_uri=h*tps://www.mydomain.com/login&response_type=code&state=bSWtGx

3 - 用户被重定向到上一个 url

4 - oauth2-server 向浏览器发送一个 302 http 代码,登录名 url oauth2-server, h*tps://oauth2.mydomain.com/oauth2-server/login

5 - 用户提交他的凭据,如果正确则创建令牌。

6 - 用户被重定向到与第二步相同的地址,并且 oauth-server 将信息添加到 redirect_uri 值

7 - 用户被重定向到客户端服务器。响应的重定向部分如下所示:

location: h*tps://www.mydomain.com/login?code=gnpZ0r&state=bSWtGx

8 - 客户端服务器联系 oauth2 服务器并从代码和验证它的状态中获取令牌。 oauth2 的实例是否无关紧要 服务器与用户用来验证自己的服务器不同。这里的 客户端-服务器使用 security.oauth2.client.accessTokenUri 的值来获取 token,这是针对oauth2服务器的内部负载均衡服务地址 pods,因此它不会通过任何 Ingress 控制器。

因此在步骤 3 到 6 中,用户必须通过负载均衡器服务前面的 Ingress 控制器与 oauth2-server 的同一实例通信。

通过使用一些注释配置 Nginx Ingress 控制器是可能的:

"annotations": {
  ...
  "nginx.ingress.kubernetes.io/affinity": "cookie",
  "nginx.ingress.kubernetes.io/session-cookie-expires": "172800",
  "nginx.ingress.kubernetes.io/session-cookie-max-age": "172800",
  "nginx.ingress.kubernetes.io/session-cookie-name": "route"
}

这样我们就可以确保用户在身份验证过程中被重定向到相同的 pods/instance oauth2 服务器,只要他使用相同的 cookie 进行标识。

关联会话机制是扩展身份验证服务器和客户端-服务器的好方法。一旦用户通过身份验证,他将始终使用相同的客户端实例并保留他的会话信息。

感谢 Christian Altamirano Ayala 的帮助。