为什么要用 Keycloak

Keycloak 是一个开源的 IdP 软件,开源项目由 RedHat 负责管理。初始版本发布于 2014 年 9 月 10 日[1] 。集成了用户注册、社会化登录、单点登录/登出、在同一 Realm 中可跨所有应用、双重认证、LDAP 集成、Kerberos 代理及多组织中,每个 Realm 可自定义皮肤等功能。

那么为什么要选择 Keycloak 呢?首先看下其他类似软件:

  • CAS[2]: 似乎是市面上最流行的 IdP 软件。
  • OpenAM[3]: 前身是 Sun 公司开发的 OpenSSO, 现在由社区维护。

CAS 似乎是基于 Spring Boot 开发的,下载部署也比较简单,官方也有维护相应的 docker 镜像。但配置需要写较多代码并进行编译。较适合二次开发,不是很适合直接拿来用。

OpenAM 要稍微友好一些,大部分配置可以通过 Web 界面进行。但因为开发时间已久,代码库十分庞大,且管理界面也处于更新换代的阶段中,新旧 UI 掺杂在一起。同时,OpenAM 中的配置看的我眼花缭乱,感觉更适合专业人士使用。

说回 Keycloak,从 docker 上 pull 到最新的镜像,在本地运行,就喜欢上了 Keycloak 的简洁。不用写那么多的配置文件,也没有那么多复杂的选项。新增 realm,新增用户,新增 client,行云流水。

配置 Keycloak

首先使用如下的 docker-compose.yml 启动一个最简单的 Keycloak 实例

version: '3.6'

services:
  image: 'jboss/keycloak'
  ports:
    - '8080:8080'

启动容器后,使用 docker exec -it <container_name/id> /bin/bash 进入容器。之后执行位于 /opt/jboss/keycloak/bin/add-user-script.sh 的脚本,创建管理账户,重启生效。

之后,打开浏览器,登入 http://localhost:8080/auth 输入账户进入后台,便可以对 keycloak 进行配置了。

创建 Realm

Realm[4] 是 OpenID 的一个概念,中文译为领域,用以提供给终端用户对于授权范围的指示。在 Keycloak 之中,不同的 Realm 可以有不同的用户及用户组,也可以定制不同的登入界面。

如何创建 Realm 这里不过多赘述。

添加用户及用户组

同上,这里不多赘述。需要注意的如果希望直接使用该用户登陆,需要在创建成功后编辑密码并将 Temporary Password 这一选项关掉。

添加客户端

由于本文以 Vue + Spring Boot 举例,且采用了前后端分离,通过 API 交互的架构,显然,无论是前端应用或者是后端 API 都是需要被保护的,。同时基于 RESTful API stateless 的原则,后端的认证不应该通过 cookie 完成。通常的做法是为请求带一个 Authorization: Bearer <token> 的 header。

首先进入 Clients > Add Client 添加一个客户端

Screenshot-2019-10-26-at-16.32.28

之后进入编辑该客户端,设置 Access Type 为 bearer-only

Screenshot-2019-10-26-at-16.33.55

保存,至此 API 的认证便设置成功了。

接下来重复此步骤,但在第二步编辑时将 Access Type 设置为 Public,同时指定 Root URL, Redirect URL, Web Origin 等。

Screenshot-2019-10-26-at-16.37.43

Vue 接入 Keycloak

Keycloak 官方提供了一个名为 keycloak-js 的浏览器端 JS 解决方案,有开发者对 Keycloak 进行封装,制成了 vue-keycloak-js[5]

配置过程见 vue-keycloak-js 的文档,此处也不多赘述。配置参数可从 Client 中的 Installation 获取(建议 JSON 格式)。

接入后发现可以跳转到认证页面,但在登入成功后过几秒页面便会自动刷新,这边暂时通过添加checkLoginIframe: false 这一参数解决了。

页面不会自动跳转了,但每次刷新时,页面还是会跳转到认证页面。这当然不是我们想要的结果。通过查看 cookie 发现 keycloak 并没有在本地存储 cookie。但 keycloak-js 及 vue-keycloak-js 的文档中均没有提及这一情况…… Stackoverflow 中的一篇回答给出的解决方法是手动写入及读取 local storage[6]。通过打断点和阅读源代码,发现 keycloak-js 是不会自动添加 token 到 local storage 的。只有在跳转登陆前会设置 local storage 并在跳转后清除[7]

将配置修改如下,问题解决。

const token = localStorage.getItem('kc_token');
const refreshToken = localStorage.getItem('kc_refreshToken');

Vue.use(VueKeycloakJs, {
  init: {
    // Use 'login-required' to always require authentication
    onLoad: 'login-required',
    checkLoginIframe: false,
    token: token,
    refreshToken: refreshToken
  },
  config: {
    url: process.env.VUE_APP_KEYCLOAK_URL,
    realm: process.env.VUE_APP_KEYCLOAK_REALM,
    clientId: process.env.VUE_APP_KEYCLOAK_CLIENT_ID,
  },
  onReady: (keycloak) => {
    localStorage.setItem('kc_token', keycloak.token);
    localStorage.setItem('kc_refreshToken', keycloak.refreshToken);

    new Vue({
      el: '#app',
      store,
      router,
      render: h => h(App),
    })
  }
})

注意前端发送请求时,要带上 Authorization: Bearer <token> 这一 header。

Spring Boot 接入 Keycloak

让 Spring Boot 接入 Keycloak 有两种方式,第一种是通过 Spring Boot Adapter[8], 第二种是通过 Spring Security Adapter[9]。这里我使用的是后者,因其似乎更为灵活,但前者的配置属实简单。

需要注意的是,使用 gradle 要添加以下内容

dependencies {
...
    // security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.keycloak:keycloak-spring-security-adapter'
}

dependencyManagement {
...
    imports {
        mavenBom "org.keycloak.bom:keycloak-adapter-bom:7.0.1"
    }
}

按照文档配置完成后,坑就来了,似乎是 Spring Boot 的 Bug,会提示 A bean with that name has already been defined in URL[10]。Workaround 便是将 @KeycloakConfiguration 这一注解更换为

@Configuration
@ComponentScan(
        basePackageClasses = KeycloakSecurityComponents.class,
        excludeFilters = @ComponentScan.Filter(type = FilterType.REGEX, pattern = "org.keycloak.adapters.springsecurity.management.HttpSessionManager"))
@EnableWebSecurity

重新测试,提示找不到 WEB-INF/keycloak.json 这一文件。于是我便想试着在 application.yml 里面进行配置,这需要将 KeycloakConfigResolver 换为 KeycloakSpringBootConfigResolver[11]

这里还需要添加 keycloak-springboot 的依赖

dependencies {
...
    implementation 'org.keycloak:keycloak-spring-boot-starter'
}

之后在 application.yml 中添加以下内容

keycloak:
  realm: test
  bearer-only: true
  auth-server-url: http://localhost:9001/auth
  ssl-required: external
  resource: spring-security-demo-app
  # 注意这里如果设置为 true, 使用的是 client 中的 role,反之使用 realm 中的
  use-resource-role-mappings: true
  principal-attribute: preferred_username

[12]

运行,接着报错

Parameter 1 of method setKeycloakSpringBootProperties in org.keycloak.adapters.springboot.KeycloakBaseSpringBootConfiguration required a bean of type 'org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver' that could not be found.

添加新的 Bean [13]

MyKeycloakSpringBootConfigResolver.java

@Configuration
public class MyKeycloakSpringBootConfigResolver extends KeycloakSpringBootConfigResolver {
    private final KeycloakDeployment keycloakDeployment;

    public MyKeycloakSpringBootConfigResolver(KeycloakSpringBootProperties properties) {
        keycloakDeployment = KeycloakDeploymentBuilder.build(properties);
    }

    @Override
    public KeycloakDeployment resolve(HttpFacade.Request facade) {
        return keycloakDeployment;
    }
}

SecurityConfig.java

@Bean
@Primary
public KeycloakConfigResolver keycloakConfigResolver(KeycloakSpringBootProperties properties) {
    return new MyKeycloakSpringBootConfigResolver(properties);
}

这里需要提到的是,在配置 role 时,Keycloak 似乎传给的是 authority,并不是 role。如果像我一样一直报 403,可以把 hasRole() 换成 hasAuthority 试试。

不验证 Spring Boot Preflight 请求

SecurityConfig.java

    @Override
    protected void configure(HttpSecurity http) throws Exception
    {
        super.configure(http);
        http.cors()
            .and()
            .authorizeRequests()
            .antMatchers("/api**").hasAuthority("staff").anyRequest().permitAll();
    }

Spring Boot 获取 认证信息

@Controller
public class SecurityController {
 
    @RequestMapping(value = "/username", method = RequestMethod.GET)
    @ResponseBody
    public String currentUserName(Authentication authentication) {
        return authentication.getName();
    }
}

[14]

Keycloak 接入数据库

如何接入数据库?这里面有一个深坑。Keycloak 的 JDBC URI 居然默认是 useSSL 的。。。配置了两天数据库却屡战屡败,找遍所有资料的我不得已之下开始一行一行的看 log,一个 SSLHandshakeException 成功的引起了我的注意但在下一秒却被我否定。不知道又多了多少次失败尝试后,当我抱着试一试的心态(也是基础知识没学好)在网上查找如何配置 JDBC 不使用 SSL 连接后,问题终于得以解决。

至此,完整的 docker-compose 配置如下:

version: '3.6'
  
services:
  keycloak:
    image: jboss/keycloak
    container_name: keycloak
    environment:
      - DB_VENDOR=MYSQL
      - JDBC_PARAMS=serverTimezone=Asia/Macau&useSSL=false
      - DB_ADDR=example.com
      - DB_DATABASE=keycloak
      - DB_USER=user
      - DB_PASSWORD=password
      - KEYCLOAK_USER=admin
      - KEYCLOAK_PASSWORD=password

数据库连接成功,但在 Keycloak 迁移数据表时,又有问题发生了。

13:45:45,286 ERROR [org.keycloak.connections.jpa.updater.liquibase. conn.DefaultLiquibaseConnectionProvider] (ServerService Thread Pool -- 63)
Change Set META-INF/jpa-changelog-1.9.1.xml::1.9.1::keycloak failed.
Error: (conn=16) Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535. This includes storage overhead, check the manual. You have to change some columns to TEXT or BLOBs [Failed SQL: ALTER TABLE keycloak.REALM MODIFY CERTIFICATE VARCHAR(4000)]

查阅资料[15]发现是数据库编码的原因,如果采用 utf8mb4,一个字符会占 4bytes 的大小,超出了 65536bit (4bytes = 32bit, 32bit * 4000 = 120000bit),而 latin1 等编码只占 2bytes。解决方法也很简单,便是修改字符集为 latin1,实测修改字符集不会影响汉字存储。

使用 Nginx 作为 Keycloak 的反向代理

这里需要添加一个 keycloak 的环境变量 PROXY_ADDRESS_FORWARDING=true,并且使 Nginx 可以访问到 Keycloak,同时在 Nginx 的站点配置中,要添加以下 HTTP header[16],否则会在登入时报 invalid redirect url 这一错误

proxy_set_header        Host $host;
proxy_set_header        X-Real-IP $remote_addr;
proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header        X-Forwarded-Proto $scheme;

持久化 Keycloak 配置

// TODO

Spring Boot 开发环境关闭 Keycloak 认证

// TODO

References


  1. https://zh.wikipedia.org/wiki/Keycloak ↩︎

  2. https://en.wikipedia.org/wiki/CAS ↩︎

  3. https://en.wikipedia.org/wiki/OpenAM ↩︎

  4. https://openid.net/specs/openid-authentication-2_0-12.html#realms ↩︎

  5. https://github.com/dsb-norge/vue-keycloak-js ↩︎

  6. https://stackoverflow.com/questions/50580338/keycloak-redirects-to-login-on-page-refresh ↩︎

  7. https://github.com/keycloak/keycloak-js-bower/blob/master/dist/keycloak.js#L1297 ↩︎

  8. https://www.keycloak.org/docs/latest/securing_apps/index.html#_spring_boot_adapter ↩︎

  9. https://www.keycloak.org/docs/latest/securing_apps/index.html#_spring_security_adapter ↩︎

  10. https://issues.jboss.org/browse/KEYCLOAK-8725 ↩︎

  11. https://sandor-nemeth.github.io/java/spring/2017/06/15/spring-boot-with-keycloak.html ↩︎

  12. https://www.keycloak.org/docs/latest/securing_apps/index.html#_java_adapter_config ↩︎

  13. https://stackoverflow.com/questions/57787768/issues-running-example-keycloak-spring-boot-app ↩︎

  14. https://www.baeldung.com/get-user-in-spring-security ↩︎

  15. https://www.janua.fr/how-to-install-keycloak-with-mariadb/ ↩︎

  16. https://stackoverflow.com/questions/53564499/keycloak-invalid-parameter-redirect-uri-behind-a-reverse-proxy ↩︎