Camunda是一个流行的工作流平台,其自带了基本的用户管理功能。Keycloak是业界主流的一个提供OAUTH等协议标准的一个用户验证与授权的平台。这里介绍如何把Camunda与Keycloak相集成,以实现通过Keycloak来统一管理用户的鉴权与授权,用户通过从Keycloak获取Token来调用Camunda的API。这篇文章也是参考了Github的这个仓库来写的,并经过实际测试有效:GitHub - camunda-community-hub/camunda-platform-7-keycloak: Camunda Keycloak Identity Provider Plugin
Keycloak的设置
这里采用Keycloak+PG数据库的方式来启动。PG数据库是和Camunda共用的。通过以下Docker命令来启动:
docker run --name pg12 -v /home/roy/data/pgdata:/var/lib/postgresql/data -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:12
这里采用Docker的方式来启动Keycloak,命令如下:
docker run -p 9090:8080 --name keycloak --link pg12 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=XXXXX quay.io/keycloak/keycloak:18.0.0 start-dev --db-url jdbc:postgresql://pg12:5432/keycloak --db-username postgres --db-password postgres --db=postgres
运行之后就可以访问localhost:9090来进入到Keycloak的设置了。
在Keycloak里面新建一个名为camunda的realm,然后在clients里面新建一个名为camunda-identity-service的client,配置如下:
为了能使用refresh token,需要在OpenID Connect Compatibility Modes里面开启选项Use Refresh Tokens For Client Credentials Grant, 如下图:
在Service Account Roles里面添加query-groups, query-users, view-users这3个Role,如下图:
在这个camunda的realm里面新建一个用户组camunda-admin,如下图:
Keycloak的设置完成之后,我们就可以用Springboot来集成Camunda了
集成Springboot与Camunda
首先我们需要新建一个Springboot的应用,集成Camunda。具体可以参照我之前的文章:Springboot集成Camunda流程引擎_gzroy的博客-CSDN博客
添加Keycloak插件
在pom.xml文件里面增加以下依赖:
<dependency>
<groupId>org.camunda.bpm.extension</groupId>
<artifactId>camunda-platform-7-keycloak</artifactId>
<version>${camunda.spring-boot.version}</version>
</dependency>
在src目录新建一个plugin的目录,里面新建一个KeycloakIdentityProvider.java,代码如下:
package com.roy.camunda.plugin;
import org.camunda.bpm.extension.keycloak.plugin.KeycloakIdentityProviderPlugin;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix="plugin.identity.keycloak")
public class KeycloakIdentityProvider extends KeycloakIdentityProviderPlugin {
}
在application.yml配置文件中添加以下配置:
camunda.bpm:
authorization:
enabled: true
plugin.identity.keycloak:
keycloakIssuerUrl: http://localhost:9090/realms/camunda
keycloakAdminUrl: http://localhost:9090/admin/realms/camunda
clientId: camunda-identity-service
clientSecret: XXXXXXXXXXXXXXXX
useEmailAsCamundaUserId: false
useUsernameAsCamundaUserId: true
useGroupPathAsCamundaGroupId: true
administratorGroupName: camunda-admin
disableSSLCertificateValidation: true
设置Springboot Security
需要在pom.xml里面添加以下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
需要新增一个KeycloakAuthenticationProvider来连接Spring security和Camunda。在src里面新建一个目录sso,里面新建一个KeycloakAuthenticationProvider.java文件,内容如下:
package com.roy.camunda.sso;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import org.camunda.bpm.engine.ProcessEngine;
import org.camunda.bpm.engine.rest.security.auth.AuthenticationResult;
import org.camunda.bpm.engine.rest.security.auth.impl.ContainerBasedAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.util.StringUtils;
/**
* OAuth2 Authentication Provider for usage with Keycloak and KeycloakIdentityProviderPlugin.
*/
public class KeycloakAuthenticationProvider extends ContainerBasedAuthenticationProvider {
@Override
public AuthenticationResult extractAuthenticatedUser(HttpServletRequest request, ProcessEngine engine) {
// Extract user-name-attribute of the OAuth2 token
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (!(authentication instanceof OAuth2AuthenticationToken) || !(authentication.getPrincipal() instanceof OidcUser)) {
return AuthenticationResult.unsuccessful();
}
String userId = ((OidcUser)authentication.getPrincipal()).getName();
if (!StringUtils.hasLength(userId)) {
return AuthenticationResult.unsuccessful();
}
// Authentication successful
AuthenticationResult authenticationResult = new AuthenticationResult(userId, true);
authenticationResult.setGroups(getUserGroups(userId, engine));
return authenticationResult;
}
private List<String> getUserGroups(String userId, ProcessEngine engine){
List<String> groupIds = new ArrayList<>();
// query groups using KeycloakIdentityProvider plugin
engine.getIdentityService().createGroupQuery().groupMember(userId).list()
.forEach( g -> groupIds.add(g.getId()));
return groupIds;
}
}
保护REST API
我们使用JWT来保护rest API的调用,如以下流程:
- 客户端在调用camunda rest API之前需要先从keycloak获取JWT
- 客户端在调用API的header里面设置JWT
- Camunda验证Token并从token中获取用户id和group id
在application.yml里面增加以下配置:
# Camunda Rest API
rest.security:
enabled: true
provider: keycloak
required-audience: camunda-rest-api
为了能让keycloak在生成的JWT里面包括Camunda期望的audience claim,需要配置一个Client Scope,名称为camunda-rest-api,如下图:
然后再mappers里面新增一个mapper,类型为Audience,然后配置需要的audience camunda-rest-api,如下图:
最后把这个client scope加到之前创建的client Camunda-Identity-Service中,如下图:
以上的配置可以使得经过Camunda-Identity-Service验证的用户能够访问Camunda的rest API
在src目录下新建一个目录rest,然后新建一个文件RestApiSecurityConfig.java,内容如下,其中configure和jwtDecoder这两个函数中被注释的部分是原代码,我测试了发现无法检验token里面的aud以及对用户的role进行校验,因此需要改造一下。另外还新增了一个函数对Keycloak的token中的Role进行转换,即增加ROLE_的前缀。这个设置可以确保只有用户具有admin的role,并且client的audience是camunda-rest-api的才有权限访问engine-rest/的API:
package com.roy.camunda.rest;
import javax.inject.Inject;
import org.camunda.bpm.engine.IdentityService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
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.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
/**
* Optional Security Configuration for Camunda REST Api.
*/
@Configuration
@EnableWebSecurity
@Order(SecurityProperties.BASIC_AUTH_ORDER - 20)
@ConditionalOnProperty(name = "rest.security.enabled", havingValue = "true", matchIfMissing = true)
public class RestApiSecurityConfig extends WebSecurityConfigurerAdapter {
/** Configuration for REST Api security. */
@Inject
private RestApiSecurityConfigurationProperties configProps;
/** Access to Camunda's Identity Service. */
@Inject
private IdentityService identityService;
/** Access to Spring Security OAuth2 client service. */
@Inject
private OAuth2AuthorizedClientService clientService;
@Inject
private ApplicationContext applicationContext;
/**
* {@inheritDoc}
*/
@Override
public void configure(final HttpSecurity http) throws Exception {
/*
String jwkSetUri = applicationContext.getEnvironment().getRequiredProperty(
"spring.security.oauth2.client.provider." + configProps.getProvider() + ".jwk-set-uri");
http
.csrf().ignoringAntMatchers("/api/**", "/engine-rest/**")
.and()
.antMatcher("/engine-rest/**")
.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2ResourceServer()
.jwt().jwkSetUri(jwkSetUri)
;
*/
http.authorizeRequests(authorizeRequests -> authorizeRequests
.antMatchers("/engine-rest/**").hasRole("admin")
.anyRequest().authenticated()).oauth2ResourceServer(
oauth2ResourceServer -> oauth2ResourceServer.jwt(
jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
}
private Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());
return jwtConverter;
}
/**
* Create a JWT decoder with issuer and audience claim validation.
* @return the JWT decoder
*/
@Bean
public JwtDecoder jwtDecoder() {
/*
String issuerUri = applicationContext.getEnvironment().getRequiredProperty(
"spring.security.oauth2.client.provider." + configProps.getProvider() + ".issuer-uri");
NimbusJwtDecoder jwtDecoder = (NimbusJwtDecoder)
JwtDecoders.fromOidcIssuerLocation(issuerUri);
OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator(configProps.getRequiredAudience());
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);
jwtDecoder.setJwtValidator(withAudience);
*/
String jwkSetUri = applicationContext.getEnvironment().getRequiredProperty(
"spring.security.oauth2.client.provider." + configProps.getProvider() + ".jwk-set-uri");
String issuerUri = applicationContext.getEnvironment().getRequiredProperty(
"spring.security.oauth2.client.provider." + configProps.getProvider() + ".issuer-uri");
OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator(configProps.getRequiredAudience());
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
jwtDecoder.setJwtValidator(withAudience);
return jwtDecoder;
}
/**
* Registers the REST Api Keycloak Authentication Filter.
* @return filter registration
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
@Bean
public FilterRegistrationBean keycloakAuthenticationFilter(){
FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
filterRegistration.setFilter(new KeycloakAuthenticationFilter(identityService, clientService));
filterRegistration.setOrder(102); // make sure the filter is registered after the Spring Security Filter Chain
filterRegistration.addUrlPatterns("/engine-rest/*");
return filterRegistration;
}
}
新增一个文件KeycloakRealmRoleConverter.java,进行Keycloak role的转换,内容如下:
package com.roy.camunda.rest;
import java.util.Collection;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.List;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
public class KeycloakRealmRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
final Map<String, Object> realmAccess = (Map<String, Object>) jwt.getClaims().get("realm_access");
return ((List<String>)realmAccess.get("roles")).stream()
.map(roleName -> "ROLE_" + roleName) // prefix to map to a Spring Security "role"
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
}
新建一个文件RestApiSecurityConfigurationProperties.java, 内容如下:
package com.roy.camunda.rest;
import javax.validation.constraints.NotEmpty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
/**
* Complete Security Configuration Properties for Camunda REST Api.
*/
@Component
@ConfigurationProperties(prefix = "rest.security")
@Validated
public class RestApiSecurityConfigurationProperties {
/**
* rest.security.enabled:
*
* Rest Security is enabled by default. Switch off by setting this flag to {@code false}.
*/
private Boolean enabled = true;
/**
* rest.security.provider:
*
* The name of the spring.security.oauth2.client.provider to use
*/
@NotEmpty
private String provider;
/**
* rest.security.required-audience:
*
* Required Audience.
*/
@NotEmpty
private String requiredAudience;
// ------------------------------------------------------------------------
/**
* @return the requiredAudience
*/
public String getRequiredAudience() {
return requiredAudience;
}
/**
* @param requiredAudience the requiredAudience to set
*/
public void setRequiredAudience(String requiredAudience) {
this.requiredAudience = requiredAudience;
}
/**
* @return the enabled
*/
public Boolean getEnabled() {
return enabled;
}
/**
* @param enabled the enabled to set
*/
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
/**
* @return the provider
*/
public String getProvider() {
return provider;
}
/**
* @param provider the provider to set
*/
public void setProvider(String provider) {
this.provider = provider;
}
}
新增一个文件AudienceValidator.java,校验JWT中是否包括所需的audience,内容如下:
package com.roy.camunda.rest;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
/**
* Token validator for audience claims.
*/
public class AudienceValidator implements OAuth2TokenValidator<Jwt> {
/** The required audience. */
private final String audience;
/**
* Creates a new audience validator
* @param audience the required audience
*/
public AudienceValidator(String audience) {
this.audience = audience;
}
/**
* {@inheritDoc}
*/
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
if (jwt.getAudience().contains(audience)) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token", "The required audience is missing", null));
}
}
新增一个文件KeycloakAuthenticationFilter.java,其作用是注册一个过滤器到Spring security的过滤器链的末尾,把验证过的user id和group id发送给Camunda的IdentityService,内容如下:
package com.roy.camunda.rest;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import org.camunda.bpm.engine.IdentityService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.util.StringUtils;
/**
* Keycloak Authentication Filter - used for REST API Security.
*/
public class KeycloakAuthenticationFilter implements Filter {
/** This class' logger. */
private static final Logger LOG = LoggerFactory.getLogger(KeycloakAuthenticationFilter.class);
/** Access to Camunda's IdentityService. */
private IdentityService identityService;
/** Access to the OAuth2 client service. */
OAuth2AuthorizedClientService clientService;
/**
* Creates a new KeycloakAuthenticationFilter.
* @param identityService access to Camunda's IdentityService
*/
public KeycloakAuthenticationFilter(IdentityService identityService, OAuth2AuthorizedClientService clientService) {
this.identityService = identityService;
this.clientService = clientService;
}
/**
* {@inheritDoc}
*/
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// Extract user-name-attribute of the JWT / OAuth2 token
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String userId = null;
if (authentication instanceof JwtAuthenticationToken) {
userId = ((JwtAuthenticationToken)authentication).getName();
} else if (authentication.getPrincipal() instanceof OidcUser) {
userId = ((OidcUser)authentication.getPrincipal()).getName();
} else {
throw new ServletException("Invalid authentication request token");
}
if (StringUtils.isEmpty(userId)) {
throw new ServletException("Unable to extract user-name-attribute from token");
}
LOG.debug("Extracted userId from bearer token: {}", userId);
try {
identityService.setAuthentication(userId, getUserGroups(userId));
chain.doFilter(request, response);
} finally {
identityService.clearAuthentication();
}
}
/**
* Queries the groups of a given user.
* @param userId the user's ID
* @return list of groups the user belongs to
*/
private List<String> getUserGroups(String userId){
List<String> groupIds = new ArrayList<>();
// query groups using KeycloakIdentityProvider plugin
identityService.createGroupQuery().groupMember(userId).list()
.forEach( g -> groupIds.add(g.getId()));
return groupIds;
}
}
测试
现在我们可以在这个Springboot项目中输入mvn clean install,以及mvn spring-boot:run来运行Camunda引擎了。
在Keycloak中创建一个新的用户并加入到之前创建的camunda-admin组里面,新建一个client,例如名称为test-client,在Setting里面的Implicit Flow Enabled设置为ON,client scopes里面把camunda-rest-api赋予到assigned default client scopes。
在Keycloak里面创建一个名为admin的role,并把这个role赋予给camunda-admin。
这时会去到Keycloak的页面,输入之前创建的用户名和密码,验证通过之后在返回的网页中,URL里面会包括了access token,拷贝这个token。
然后我们测试调用camunda的API,例如我们调用create deployment的API,创建一个工作流的部署,http://localhost:8080/camunda/engine-rest/deployment/create,直接调用会返回401 unauthorized的错误。在headers里面加入Authorization: Bearer token之后再调用,这是返回了403的错误,消息是没有对Deployment这个resource进行create的Permission。这是我就纳闷了,按照正常设置,用户已经是在camunda-admin这个组里面了,而且在Camunda启动的时候也看到日志显示这个用户组已经获得了所有Resource的ALL Permission,为什么还会出现问题呢?网上搜索了很久也没找到答案,后来我在application.xml里面增加了两行配置,打印DEBUG的日志:
logging.level.org.camunda.bpm.extension.keycloak: DEBUG
logging.level.org.springframework.web.client.RestTemplate: DEBUG
重新启动Camunda,按照之前的流程再次调用创建deployment的API,这次我从日志中看到了以下信息:
2022-10-02 16:38:41.381 DEBUG 14471 --- [nio-8080-exec-6] o.c.b.e.k.rest.KeycloakRestTemplate : HTTP GET http://localhost:9090/admin/realms/camunda/users?username=b9b42747-01df-4f8c-9520-847de7fa7893
2022-10-02 16:38:41.382 DEBUG 14471 --- [nio-8080-exec-6] o.c.b.e.k.rest.KeycloakRestTemplate : Accept=[text/plain, application/json, application/*+json, */*]
2022-10-02 16:38:41.408 DEBUG 14471 --- [nio-8080-exec-6] o.c.b.e.k.rest.KeycloakRestTemplate : Response 200 OK
2022-10-02 16:38:41.408 DEBUG 14471 --- [nio-8080-exec-6] o.c.b.e.k.rest.KeycloakRestTemplate : Reading to [java.lang.String] as "application/json"
2022-10-02 16:38:41.412 DEBUG 14471 --- [nio-8080-exec-6] org.camunda.bpm.extension.keycloak : KEYCLOAK-01050 Keycloak group query results: []
感觉camunda调用keycloak的API来查这个用户的信息,但是查到的结果为空。我进keycloak看了一下,发现Camunda调用keycloak API的usename参数其实对应的是keycloak的用户ID,这样是查不到数据的,如果把这个参数改为对应的keycloak的用户名,则能查到数据。看来定位到问题了。再回到这个Camunda的keycloak插件的描述中,可以看到对于useUsernameAsCamundaUserId这个选项的描述是:
Whether to use the Keycloak username attribute as Camunda's user ID. Default is false
. In the default case the plugin will use the internal Keycloak ID as Camunda's user ID.
按字面理解,我原来以为如果设置为true则表示用keycloak的用户名来作为Camunda的用户ID,但是我现在设置了true,Camunda还是用keycloak的用户ID来查询,和我的理解不一样。我把这个选项设置为false,再次测试,发现这次就可以了,没有再报403错误了。
最后总结一下,按照本文的设置,如果需要设置用户访问Camunda的权限,那么需要以下步骤的设置:
- 在Keycloak里面配置用户,赋予用户对应的role,把用户加到camunda-admin的组里
- 用户调用Keycloak的API来获取token,这里需要用有camunda-rest-api client role的client来获取token
如果需要更进一步细化用户的权限管理,可以参考Camunda文档中关于Authorization的描述,赋予用户更细的控制力度。