在Spring Boot应用中集成Keycloak作认证和鉴权

董同
2023-12-01

在Spring Boot应用中集成Keycloak作认证和鉴权

前言

本文描述了在Spring Boot应用中通过Spring Security集成Keycloak来实现用认证和鉴权。

工具和环境:

  • Spring Boot 2.4.0
  • Spring Security
  • Spring Boot Thymeleaf
  • Keycloak 12.0.1

引入依赖

Spring Security依赖

<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-test</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.keycloak</groupId>
  <artifactId>keycloak-spring-boot-starter</artifactId>
</dependency>

Keycloak依赖

<dependency>
  <groupId>org.keycloak</groupId>
  <artifactId>keycloak-spring-boot-starter</artifactId>
</dependency>

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.keycloak.bom</groupId>
      <artifactId>keycloak-adapter-bom</artifactId>
      <version>12.0.1</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

Thymeleaf依赖

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
  <groupId>org.thymeleaf.extras</groupId>
  <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

安装Keycloak

以Docker方式安装Keycloak:

#!/bin/bash

# Create a user defined network
docker network create keycloak-network

# Start a MySQL instance
docker run --name keycloak-mysql \
  -d \
  --net keycloak-network \
  -v $HOME/keycloak/mysql-data:/var/lib/mysql \
  -e MYSQL_DATABASE=keycloak \
  -e MYSQL_USER=keycloak \
  -e MYSQL_PASSWORD=keycloak123 \
  -e MYSQL_ROOT_PASSWORD=keycloak123 \
  mysql:8.0

# Start a Keycloak instance
docker run --name keycloak \
  -d \
  --net keycloak-network \
  -p 8180:8080 \
  -e DB_VENDOR=mysql \
  -e DB_ADDR=keycloak-mysql \
  -e DB_DATABASE=keycloak \
  -e DB_USER=keycloak \
  -e DB_PASSWORD=keycloak123 \
  -e KEYCLOAK_USER=admin \
  -e KEYCLOAK_PASSWORD=admin \
  quay.io/keycloak/keycloak:12.0.1

# check logs
# docker logs -f keycloak

说明:

  • 采用MySQL来持久化Keycloak配置。
  • 设置Keycloak的端口为8180

参见:

在Keycloak上配置

在Keycloak上新建Realm、Client、Role和User:

  • 创建一个新Realm - xdevops
  • 在该Realm下创建一个Client
    • Client ID - springboot-keycloak-demo
    • Root URL - http://localhost:8080/ (对应的Valid Redirect URL为http://localhost:8080/*)
  • 在该Realm下创建两个Role
    • admin - 管理员
    • user - 普通用户
  • admin 角色下创建william用户,在user角色下创建john用户。

参见:

构建Spring Boot应用

配置Keycloak属性

application.yaml中配置Keycloak属性:

keycloak:
  # the name of the realm, required
  realm: xdevops
  # the client-id of the application, required
  resource: springboot-keycloak-demo
  # the base URL of the Keycloak server, required
  auth-server-url: http://localhost:8180/auth
  # establishes if communications with the Keycloak server must happen over HTTPS
  # set to external, meaning that it's only needed for external requests (default value)
  # In production, instead, we should set it to all. Optional
  ssl-required: external
  # prevents the application from sending credentials to the Keycloak server (false is the default value)
  # set it to true whenever we use public clients instead of confidential
  public-client: true
  # the attribute with which to populate the UserPrincipal name
  principal-attribute: preferred_username

说明:

  • realm 为上面创建的Relam。

  • resource为上面创建的Client ID。

  • auth-server-url 为Keycloak server的auth url。

  • 默认创建的Client的Access Type为public,所以这里设置public-clienttrue

  • principal-attribute: preferred_username 表示用Keycloak User的preferred_username 属性作为Spring Security Principal的name

如果需要配置Client的Access Type为confidential,则需要在应用配置中设置public-client 为false,并提供Client Secret。
示例如下:

# prevents the application from sending credentials to the Keycloak server (false is the default value)
  # set it to true whenever we use public clients instead of confidential
  # when access type in keycloak is `confidential` must set `public-client: false`
  public-client: false
  # client secret: client id and secret
  # https://www.keycloak.org/docs/latest/securing_apps/index.html#_client_authentication_adapter
  credentials:
    secret: "<client-secret>"

参见:

可以在Keycloak中查看Client Settings,打开Installation页,选择“Keycloak OIDC JSON”格式来查看Keycloak属性:
示例:

{
  "realm": "xdevops",
  "auth-server-url": "http://localhost:8180/auth/",
  "ssl-required": "external",
  "resource": "springboot-keycloak-demo",
  "credentials": {
    "secret": "<client-secret>"
  },
  "confidential-port": 0
}

Keycloak安全配置

创建一个SecurityConfig类:

import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver;
import org.keycloak.adapters.springsecurity.KeycloakSecurityComponents;
import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider;
import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;

@Configuration
@EnableWebSecurity
@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class)
public class SecurityConfig extends KeycloakWebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        super.configure(http);
        http.authorizeRequests()
                    .antMatchers("/manager").hasRole("admin")
                    .antMatchers("/books").hasAnyRole("user", "admin")
                    .anyRequest().permitAll();
    }

    /**
     * Make sure roles are not prefixed with ROLE_.
     * @param builder
     */
    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder builder) {
        KeycloakAuthenticationProvider provider = keycloakAuthenticationProvider();
        provider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
        builder.authenticationProvider(provider);
    }

    /**
     * Use the Spring Boot application properties file support instead of the default keycloak.json.
     * @return
     */
    @Bean
    public KeycloakSpringBootConfigResolver keycloakConfigResolver() {
        return new KeycloakSpringBootConfigResolver();
    }

    @Override
    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
        return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
    }

}

说明:

  • 访问控制
    • 配置了只有admin角色时才能访问/manager 端点。
    • 配置了只有useradmin角色时才能访问/books端点。
    • 访问其他端点,不作控制。
  • 注入configureGlobal,不让Spring Security默认在Role前添加ROLE_
  • 注入keycloakConfigResolver,让Spring Boot从application properties/yaml 中读取Keycloak配置,而不是从默认的类路径的key cloak.json中读取配置。

关于httpsecurity的用法参见:

Web层

LibraryController类中定义了两个端点:

  • /books - 普通用户或管理员都可以浏览图书。
  • /manager - 管理员才可以管理图书。
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import javax.servlet.http.HttpServletRequest;
import java.security.Principal;

@Controller
public class LibraryController {

    private final BookRepository bookRepository;

    public LibraryController(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    @GetMapping("/books")
    public String getBooks(Model model, Principal principal) {
        model.addAttribute("books", bookRepository.readAll());
        model.addAttribute("name", principal.getName());
        return "books";
    }

    @GetMapping("/manager")
    public String manageBooks(Model model, HttpServletRequest request) {
        model.addAttribute("books", bookRepository.readAll());
        model.addAttribute("name", SecurityUtils.getIDToken(request).getGivenName());
        return "manager";
    }
}

说明:

  • getBooks 方法演示了直接通过Spring Security Principal获取当前用户的名称,这里是Keycloak User的preferred_username
  • manageBooks 方法演示了通过一个工具类从request中获取Keycloak User的详细信息。

其他获取当前用户信息的方式:

// 在Controller方法中传入Authentication对象
authentication.getName()

// 在Controller方法中传入HttpServletRequest对象
request.getUserPrincipal().getName()

// 通过SecurityContextHolder工具类获取
SecurityContextHolder.getContext().getAuthentication().getName()

参见:

Keycloak工具类

import org.keycloak.KeycloakSecurityContext;
import org.keycloak.representations.IDToken;

import javax.servlet.http.HttpServletRequest;

public final class SecurityUtils {

    private SecurityUtils() {

    }

    public static KeycloakSecurityContext getKeycloakSecurityContext(HttpServletRequest request) {
        return (KeycloakSecurityContext) request.getAttribute(KeycloakSecurityContext.class.getName());
    }

    public static IDToken getIDToken(HttpServletRequest request) {
        return SecurityUtils.getKeycloakSecurityContext(request).getIdToken();
    }
}

说明:

  • getIDToken 方法返回了Keycloak security context,其中包含当前登录的Keycloak User的详细信息。

测试访问页面

在浏览器中访问http://localhost:8080

  • 全部用户都可以访问Home页。
  • 只有普通用户和管理员都可以访问Browse Books页,看到图书列表。
  • 只有管理员才可以访问Manage Library页,看到一张图书馆照片。
  • 访问Browse Books页和Manage Library页时,如果用户还未登录,则会跳转到Keycloak的登录页面。

Tips: 在Chrome浏览器中按F12, 在Console菜单中勾选“Disable Cache”来方便调试页面时不受缓存影响。

小结

本文的完整代码示例:

参考文档

 类似资料: