Spring Security OAuth 2 教程 - 7:Spring MVC 客户端应用

在本文中,我们将创建一个名为 messages-webapp 的 Spring MVC + Thymeleaf Web 应用,并使用 Keycloak 进行访问控制,使用 Spring Security OAuth 2.0 进行认证。

你可以在 Github 上找到该项目的完整源码。

使用 Docker Compose 安装 Keycloak

在上一篇文章中,我们已经了解了如何使用 Docker Compose 安装 Keycloak。

创建 docker-compose.yml 文件,内容如下:

version: '3.8'
name: spring-security-oauth2-microservices-demo
services:
  keycloak:
    image: quay.io/keycloak/keycloak:22.0.3
    command: ['start-dev']
    container_name: keycloak
    hostname: keycloak
    environment:
      - KEYCLOAK_ADMIN=admin
      - KEYCLOAK_ADMIN_PASSWORD=admin1234
    ports:
      - "9191:8080"

运行以下命令启动 Keycloak 实例:

$ docker compose up -d

现在,你可以访问 Keycloak 管理控制台 http://localhost:9191/,并使用 admin/admin1234 登录。

创建 Keycloak Realm、客户端和用户

在前面的文章中,我们已经学习了如何创建 Realm、客户端和用户。请按照 前文 中提到的步骤创建新的 Realm、客户端和用户,只需更改 “Valid redirect URIs”。

将有 Valid redirect URIs 值设置为 http://localhost:8080/login/oauth2/code/messages-webapp

现在,详细信息应该如下:

  • Keycloak Realm: sivalabs
  • Client Configuration:
    • Client ID: messages-webapp
    • Client Secret: O3SVuBs0Z25kpYoRtL5C0FhLwAnIx1CW (you might have different value)
    • Root URLhttp://localhost:8080
    • Home URLhttp://localhost:8080
    • Valid redirect URIs: http://localhost:8080/login/oauth2/code/messages-webapp
    • Valid post logout redirect URIshttp://localhost:8080/
    • Web originshttp://localhost:8080
  • User: siva/siva1234

注意到 Valid redirect URIs 的值了吗?它与我们在前几篇文章中配置的 (http://localhost:8080/callback)有所不同。Spring Security 实现了 Authentication Filter 来处理 OAuth 2.0 授权码授权流程。

在 Spring Security OAuth 2.0 实现中,redirect-uri 的默认值是 {baseUrl}/login/oauth2/code/{registrationId}。我们使用 messages-webapp 作为客户端应用的 registrationId。因此,我们需要在 Keycloak 中将有 Valid redirect URIs 配置为 http://localhost:8080/login/oauth2/code/messages-webapp

注意:

确保你已按上述要求配置了 Root URLHome URLValid redirect URIsValid post logout redirect URIsWeb origins。在结尾多加一个 "/" 或不加 "/" 可能会导致 invalid_redirect_uri 等错误。

创建 messages-webapp

点击 此链接 可使用 Spring Initializr 生成 messages-webapp。我们选择了 WebValidationOAuth2 ClientSecurityThymeleaf Starter。应用生成后,在 IDE 中打开它。

配置 OAuth 2.0 客户端注册属性

OAuth 2.0 客户端应用可以使用多个身份认证 Provider,如 Google、Facebook、GitHub、Okta、Keycloak 等。在本例中,我们只使用一个身份认证 Provider,即 Keycloak。

我们需要在 application.properties 文件中使用 spring.security.oauth2.client.registration.{registrationId}.*. 和 spring.security.oauth2.client.provider.{registrationId}.* 属性配置客户端应用的详细信息。我们使用 messages-webapp 作为 registrationId,并对属性进行如下配置:

spring.security.oauth2.client.registration.messages-webapp.client-id=messages-webapp
spring.security.oauth2.client.registration.messages-webapp.client-secret=O3SVuBs0Z25kpYoRtL5C0FhLwAnIx1CW
spring.security.oauth2.client.registration.messages-webapp.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.messages-webapp.scope=openid, profile
spring.security.oauth2.client.registration.messages-webapp.redirect-uri={baseUrl}/login/oauth2/code/messages-webapp

spring.security.oauth2.client.provider.messages-webapp.issuer-uri=http://localhost:9191/realms/sivalabs
#spring.security.oauth2.client.provider.messages-webapp.authorization-uri=http://localhost:9191/realms/sivalabs/protocol/openid-connect/auth
#spring.security.oauth2.client.provider.messages-webapp.token-uri=http://localhost:9191/realms/sivalabs/protocol/openid-connect/token
#spring.security.oauth2.client.provider.messages-webapp.jwk-set-uri=http://localhost:9191/realms/sivalabs/protocol/openid-connect/certs
#spring.security.oauth2.client.provider.messages-webapp.user-info-uri=http://localhost:9191/realms/sivalabs/protocol/openid-connect/userinfo

观察上述配置,我们注释掉了 authorization-uritoken-urijwk-set-uriuser-info-uri 属性。Spring Security OAuth 2.0 客户端实现将通过调用 {issuer-uri}/.well-known/openid-configuration 端点,自动发现这些端点。

如果访问 http://localhost:9191/realms/sivalabs/.well-known/openid-configuration,可以看到以下包含所有端点信息的响应:

{
    "issuer": "http://localhost:9191/realms/sivalabs",
    "authorization_endpoint": "http://localhost:9191/realms/sivalabs/protocol/openid-connect/auth",
    "token_endpoint": "http://localhost:9191/realms/sivalabs/protocol/openid-connect/token",
    "introspection_endpoint": "http://localhost:9191/realms/sivalabs/protocol/openid-connect/token/introspect",
    "userinfo_endpoint": "http://localhost:9191/realms/sivalabs/protocol/openid-connect/userinfo",
    "end_session_endpoint": "http://localhost:9191/realms/sivalabs/protocol/openid-connect/logout",
    "frontchannel_logout_session_supported": true,
    "frontchannel_logout_supported": true,
    "jwks_uri": "http://localhost:9191/realms/sivalabs/protocol/openid-connect/certs",
    "check_session_iframe": "http://localhost:9191/realms/sivalabs/protocol/openid-connect/login-status-iframe.html",
    "grant_types_supported": [
        "authorization_code",
        "implicit",
        "refresh_token",
        "password",
        "client_credentials",
        "urn:ietf:params:oauth:grant-type:device_code",
        "urn:openid:params:grant-type:ciba"
    ],
    ...,
  "response_types_supported": [
        "code",
        "none",
        "id_token",
        "token",
        "id_token token",
        "code id_token",
        "code token",
        "code id_token token"
    ],
    ...
    ...
}

实现主页

添加 spring-boot-starter-security 依赖后,Spring Security 将自动确保所有端点的安全。我们还添加了 spring-boot-starter-oauth2-client 依赖,它会使用在 application.properties 中配置的属性自动配置 OAuth 2.0 客户端。

创建 HomeController 类,如下:

@Controller
public class HomeController {

    @GetMapping("/")
    public String home(Model model, @AuthenticationPrincipal OAuth2User principal) {
        model.addAttribute("username", principal.getAttribute("name"));
        return "home";
    }
}

我们使用 @AuthenticationPrincipal 注解注入已通过身份认证的 User Principal Object。OAuth2User 接口代表已通过身份认证的 User Principal。

src/main/resources/templates 文件夹下创建 home.html 文件,内容如下:

<!DOCTYPE html>
<html>
<head>
    <title>Home</title>
</head>
<body>
<div>
    <h1>Welcome <span th:text="${username}">username</span></h1>
</div>
</body>
</html>

现在运行应用并访问 http://localhost:8080/,就会跳转到 Keycloak 登录页面。使用 siva/siva1234 凭证登录成功后,你将被重定向到主页,并可在主页上看到用户名。

自定义 Security 配置

Spring Security 会自动保护所有端点的安全。但是,我们希望允许未经身份认证的用户访问主页。因此,我们需要自定义 Security 配置,以允许匿名用户访问主页。

创建 SecurityConfig 类,内容如下:

package com.sivalabs.messages.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.CorsConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(c ->
                    c.requestMatchers("/").permitAll()
                    .anyRequest().authenticated()
            )
            .cors(CorsConfigurer::disable)
            .csrf(CsrfConfigurer::disable)
            .oauth2Login(Customizer.withDefaults());
        return http.build();
    }
}

由于所有人都能访问主页,所以 @AuthenticationPrincipal 可能为 null。更新 HomeController 来处理这种情况。

@Controller
public class HomeController {

    @GetMapping("/")
    public String home(Model model, @AuthenticationPrincipal OAuth2User principal) {
        if(principal != null) {
            model.addAttribute("username", principal.getAttribute("name"));
        } else {
            model.addAttribute("username", "Guest");
        }
        return "home";
    }
}

现在,如果我们重启应用并访问 http://localhost:8080/,我们将看到主页而无需进行任何身份认证。

现在,我们需要一种登录应用的方法。我们可以在主页上添加一个登录链接,如下所示:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity">
<head>
  <title>Home</title>
</head>
<body>
<div >
  <p sec:authorize="!isAuthenticated()">
    <a href="/oauth2/authorization/messages-webapp">Login</a>
  </p>

  <h1>Welcome <span th:text="${username}">username</span></h1>

</div>
</body>
</html>

我们检查用户是否已登录,并有条件地显示登录链接。我们使用 Spring Security OAuth 2.0 默认登录 URL /oauth2/authorization/{registrationId} 来启动 OAuth 2.0 授权码授权流程。

现在访问 http://localhost:8080/,你将看到登录链接。点击 “Login” 链接,就会跳转到 Keycloak 登录页面。登录成功后,你将被重定向到主页,并在主页上看到用户名。

实现注销

默认情况下,Spring Security OAuth 2.0 客户端实现会配置注销功能,这样就可以通过调用 URL /logout 来进行注销。然后,HTTP Session 将失效,SecurityContextHolder 将被清除,然后重定向到配置的 “Valid post logout redirect URIs”。

如果要自定义注销功能,可以按以下步骤更新 SecurityConfig

package com.sivalabs.messages.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.CorsConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
  private final ClientRegistrationRepository clientRegistrationRepository;

  public SecurityConfig(ClientRegistrationRepository clientRegistrationRepository) {
    this.clientRegistrationRepository = clientRegistrationRepository;
  }

  @Bean
  SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .authorizeHttpRequests(c ->
                    c.requestMatchers("/").permitAll()
                            .anyRequest().authenticated()
            )
            .cors(CorsConfigurer::disable)
            .csrf(CsrfConfigurer::disable)
            .oauth2Login(Customizer.withDefaults())
            .logout(logout -> logout
                    .clearAuthentication(true)
                    .invalidateHttpSession(true)
                    .logoutSuccessHandler(oidcLogoutSuccessHandler())
            );
    return http.build();
  }

  private LogoutSuccessHandler oidcLogoutSuccessHandler() {
    OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler =
            new OidcClientInitiatedLogoutSuccessHandler(this.clientRegistrationRepository);
    oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}/");
    return oidcLogoutSuccessHandler;
  }
}

总结

在本文中,我们创建了 messages-webapp 客户端应用,并使用 Spring Security OAuth 2.0 “授权码模式”(Authorization Code Flow) 进行访问控制。

在下一篇文章中,我们将创建 messages-service 资源服务器,并使用 Spring Security OAuth 2.0 进行访问控制,然后从 messages-webapp 调用其 API。


参考:https://www.sivalabs.in/spring-security-oauth2-tutorial-securing-springmvc-client-application/