简介
keycloak是一个非常强大的权限认证系统,我们使用keycloak可以方便的实现SSO的功能。虽然keycloak底层使用的wildfly,但是提供了非常方便的Client Adapters和各种服务器进行对接,比如wildfly,tomcat,Jetty等。
对于最流行的SpringBoot来说,keycloak有官方Adapter,只需要修改配置即可。如果非SpringBoot应用呢,那就只能使用Java Servlet Filter Adapter了。
SpringBoot接入keycloak的例子比较多,我就不赘述了。这里只简单说明下。
接入前的前置准备
在接入各种应用之前,需要在keycloak中做好相应的配置。一般来说需要使用下面的步骤:
-
创建新的realm
一般来说,为了隔离不同类型的系统,我们建议为不同的client创建不同的realm。当然,如果这些client是相关联的,则可以创建在同一个realm中。
-
创建新的用户和角色。
用户是用来登录keycloak用的,如果是不同的realm,则需要分别创建用户。用户密码也是在这一步创建的
-
添加和配置client
这一步是非常重要的,我们需要根据应用程序的不同,配置不同的root URL,redirect URI等。
还可以配置mapper和scope信息。
最后,如果是服务器端的配置的话,还需要installation的一些信息。
有了这些基本的配置之后,我们就可以准备接入应用程序了。
Springboot接入keycloak
引入依赖
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-spring-boot-starter</artifactId>
<version>11.0.2</version>
</dependency>
然后配置application.yml
keycloak:
auth-server-url: http://localhost:8080/auth
realm: wildfly
public-client: true
resource: product-app
securityConstraints:
- authRoles:
# 以下路径需要user角色才能访问
- user
securityCollections:
# name可以随便写
- name: user-role-mappings
patterns:
- /users/*
至此就接入完成了,不需要编写任何代码。
获取KeycloakSecurityContext
但是实际使用中,光能控制登陆权限还不够,业务代码中还需要能获取到当前角色,用户名等信息,这就需要用到KeycloakSecurityContext了。KeycloakSecurityContext是keycloak的上下文,我们可以从其中获取到AccessToken,IDToken,AuthorizationContext和realm信息。
Identity.java
import java.util.List;
import org.keycloak.AuthorizationContext;
import org.keycloak.KeycloakSecurityContext;
import org.keycloak.representations.idm.authorization.Permission;
/**
* <p>This is a simple facade to obtain information from authenticated users. You should see usages of instances of this class when
* rendering the home page (@code home.ftl).
*
* <p>Instances of this class are are added to models as attributes in order to make them available to templates.
*
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
* @see com.github.your.demo.controller.HomeController
*/
public class Identity {
private final KeycloakSecurityContext securityContext;
public Identity(KeycloakSecurityContext securityContext) {
this.securityContext = securityContext;
}
/**
* An example on how you can use the {@link org.keycloak.AuthorizationContext} to check for permissions granted by Keycloak for a particular user.
*
* @param name the name of the resource
* @return true if user has was granted with a permission for the given resource. Otherwise, false.
*/
public boolean hasResourcePermission(String name) {
return getAuthorizationContext().hasResourcePermission(name);
}
/**
* An example on how you can use {@link KeycloakSecurityContext} to obtain information about user's identity.
*
* @return the user name
*/
public String getName() {
return securityContext.getIdToken().getPreferredUsername();
}
/**
* An example on how you can use the {@link org.keycloak.AuthorizationContext} to obtain all permissions granted for a particular user.
*
* @return
*/
public List<Permission> getPermissions() {
return getAuthorizationContext().getPermissions();
}
/**
* Returns a {@link AuthorizationContext} instance holding all permissions granted for an user. The instance is build based on
* the permissions returned by Keycloak. For this particular application, we use the Entitlement API to obtain permissions for every single
* resource on the server.
*
* @return
*/
private AuthorizationContext getAuthorizationContext() {
return securityContext.getAuthorizationContext();
}
}
使用
@RestController
public class HomeController {
private Logger logger = LoggerFactory.getLogger(HomeController.class);
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private HttpServletRequest request;
@RequestMapping("/users")
@ResponseBody
public List<Users> users() {
logIdentity();
logger.info("使用JdbcTemplate查询数据库");
String sql = "SELECT * FROM users ";
List<Users> queryAllList = jdbcTemplate.query(sql, new Object[]{},
new BeanPropertyRowMapper<>(Users.class));
logger.info("查询用户列表" + queryAllList);
return queryAllList;
}
@RequestMapping("/")
public String home() {
return "Hello Docker World";
}
private void logIdentity() {
KeycloakSecurityContext context=getKeycloakSecurityContext();
if(context!=null){
Identity identity=new Identity(context);
logger.info("KeycloakSecurityContext identity={}",identity);
}else{
logger.info("KeycloakSecurityContext is null");
}
}
private KeycloakSecurityContext getKeycloakSecurityContext() {
return (KeycloakSecurityContext) request.getAttribute(KeycloakSecurityContext.class.getName());
}
}
Identity类来自Keycloak的官方example。上面介绍的Spring Boot中的其实是隐藏的做法,adaptor自动为我们做了和Keycloak认证服务连接的事情,如果我们需要手动去处理,则需要用到Authorization Client Java API。
添加maven依赖:
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-authz-client</artifactId>
<version>${KEYCLOAK_VERSION}</version>
</dependency>
</dependencies>
具体使用AuthzClient可查看官方文档。
Rest接口
有了这些配置,我们基本上就可以创建一个基于spring boot和keycloak的一个rest服务了。
假如我们为keycloak的client创建了新的用户:alice。
第一步我们需要拿到alice的access token,则可以这样操作:
export access_token=$(\
curl -X POST http://localhost:8180/auth/realms/spring-boot-quickstart/protocol/openid-connect/token \
-H 'Authorization: Basic YXBwLWF1dGh6LXJlc3Qtc3ByaW5nYm9vdDpzZWNyZXQ=' \
-H 'content-type: application/x-www-form-urlencoded' \
-d 'username=alice&password=alice&grant_type=password' | jq --raw-output '.access_token' \
)
这个命令是直接通过用户名密码的方式去keycloak服务器中拿取access_token,除了access_token,这个命令还会返回refresh_token和session state的信息。
因为是直接和keycloak进行交换,所以keycloak的directAccessGrantsEnabled一定要设置为true。
上面命令中的Authorization是什么值呢?
这个值是为了防止未授权的client对keycloak服务器的非法访问,所以需要请求客户端提供client-id和对应的client-secret并且以下面的方式进行编码得到的:
Authorization: basic BASE64(client-id + ':' + client-secret)
access_token是JWT格式的,我们可以简单解密一下上面命令得出的token:
{
alg: "RS256",
typ: "JWT",
kid: "FJ86GcF3jTbNLOco4NvZkUCIUmfYCqoqtOQeMfbhNlE"
}.
{
exp: 1603614445,
iat: 1603614145,
jti: "b69c784d-5b2d-46ad-9f8d-46214add7afb",
iss: "http://localhost:8180/auth/realms/spring-boot-quickstart",
sub: "e6606d93-99f6-4829-ba99-1329be604159",
typ: "Bearer",
azp: "app-authz-springboot",
session_state: "bdc33764-fd1a-400e-9fe0-90a82f4873c1",
acr: "1",
allowed-origins: [
"http://localhost:8080"
],
realm_access: {
roles: [
"user"
]
},
scope: "email profile",
email_verified: false,
preferred_username: "alice"
}.
[signature]
有了access_token,我们就可以根据access_token去做很多事情了。
比如:访问受限的资源:
curl http://localhost:8080/api/resourcea \
-H "Authorization: Bearer "$access_token
这里的api/resourcea只是我们本地spring boot应用中一个非常简单的请求资源链接,一切的权限校验工作都会被keycloak拦截,我们看下这个api的实现:
@RequestMapping(value = "/api/resourcea", method = RequestMethod.GET)
public String handleResourceA() {
return createResponse();
}
private String createResponse() {
return "Access Granted";
}
可以看到这个只是一个简单的txt返回,但是因为有keycloak的加持,就变成了一个带权限的资源调用。
上面的access_token解析过后,我们可以看到里面是没有包含权限信息的,我们可以使用access_token来交换一个特殊的RPT的token,这个token里面包含用户的权限信息:
curl -X POST \
http://localhost:8180/auth/realms/spring-boot-quickstart/protocol/openid-connect/token \
-H "Authorization: Bearer "$access_token \
--data "grant_type=urn:ietf:params:oauth:grant-type:uma-ticket" \
--data "audience=app-authz-rest-springboot" \
--data "permission=Default Resource" | jq --raw-output '.access_token'
将得出的结果解密之后,看下里面的内容:
{
alg: "RS256",
typ: "JWT",
kid: "FJ86GcF3jTbNLOco4NvZkUCIUmfYCqoqtOQeMfbhNlE"
}.
{
exp: 1603614507,
iat: 1603614207,
jti: "93e42d9b-4bc6-486a-a650-b912185c35db",
iss: "http://localhost:8180/auth/realms/spring-boot-quickstart",
aud: "app-authz-springboot",
sub: "e6606d93-99f6-4829-ba99-1329be604159",
typ: "Bearer",
azp: "app-authz-springboot",
session_state: "bdc33764-fd1a-400e-9fe0-90a82f4873c1",
acr: "1",
allowed-origins: [
"http://localhost:8080"
],
realm_access: {
roles: [
"user"
]
},
authorization: {
permissions: [
{
rsid: "e26d5d63-5976-4959-8683-94b7d85318e7",
rsname: "Default Resource"
}
]
},
scope: "email profile",
email_verified: false,
preferred_username: "alice"
}.
[signature]
可以看到,这个RPT和之前的access_token的区别是这个里面包含了authorization信息。
我们可以将这个RPT的token和之前的access_token一样使用。
Jetty+Jersey框架接入Keycloak
我们有一个老系统,用的embeded Jetty+Jersey,虽然官方提供了Jetty 9.x Adapters,但这是针对standalone而言的,现在几乎没人这么用了,所以还是得自己来。官方有Java Servlet Filter Adapter的教程,但是用的是web.xml的例子,而且语焉不详,所以这里就我自己的摸索提供一点参考。
Jetty整合Jersey框架
先来看一下Jetty+Jersey的原生例子,涉及两个文件 App.java
package xyz.chen;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.glassfish.jersey.servlet.ServletContainer;
public class App {
public static void main(String[] args) {
Server server = new Server(9999);
ServletContextHandler context = new ServletContextHandler(ServletContextHandler.NO_SESSIONS);
context.setContextPath("/");
server.setHandler(context);
// 配置Servlet
ServletHolder holder = context.addServlet(ServletContainer.class.getCanonicalName(), "/rest/*");
holder.setInitOrder(1);
holder.setInitParameter("jersey.config.server.provider.packages", "xyz.chen");
holder.setInitParameter("jersey.config.server.provider.classnames", "org.glassfish.jersey.server.filter.CsrfProtectionFilter");
try {
server.start();
server.join();
} catch (Exception e) {
e.printStackTrace();
} finally {
server.destroy();
}
}
}
然后是业务类: HelloResource.java
package xyz.chen;
import javax.ws.rs.*;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.PathSegment;
import javax.ws.rs.core.Response;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
@Path("hello")
public class HelloResource {
@Path("index")
@GET
@Consumes(MediaType.TEXT_PLAIN)
@Produces(MediaType.TEXT_PLAIN)
public Response helloworld() {
return Response.ok("hello jersey").build();
}
@GET
@Path("/user/{userName}")
public Response getThemeCss(@PathParam("userName") String userName) {
StringBuilder sb = new StringBuilder(userName);
return Response.ok(sb.toString()).build();
}
}
pom.xml文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>jersey-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<name>Maven</name>
<url>http://maven.apache.org/</url>
<inceptionYear>2001</inceptionYear>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.glassfish.jersey.core</groupId>
<artifactId>jersey-server</artifactId>
<version>2.27</version>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.inject</groupId>
<artifactId>jersey-hk2</artifactId>
<version>2.27</version>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.containers</groupId>
<artifactId>jersey-container-servlet-core</artifactId>
<version>2.27</version>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.containers</groupId>
<artifactId>jersey-container-jetty-http</artifactId>
<version>2.27</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
<version>9.4.12.v20180830</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-servlet</artifactId>
<version>9.4.12.v20180830</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-util</artifactId>
<version>9.4.12.v20180830</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>2.4.3</version>
<configuration>
<createDependencyReducedPom>true</createDependencyReducedPom>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<Main-Class>xyz.chen.App</Main-Class>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
运行就不再举例了。
整合keycloak
引入依赖
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-servlet-filter-adapter</artifactId>
<version>11.0.2</version>
</dependency>
修改App类,加入Filter
ServletHandler handler = new ServletHandler();
FilterHolder fh=handler.addFilterWithMapping(org.keycloak.adapters.servlet.KeycloakOIDCFilter.class,"/*", EnumSet.of(DispatcherType.REQUEST));
fh.setInitParameter("keycloak.config.file", "keycloak.json");
context.addFilter(fh, "/*", EnumSet.of(DispatcherType.REQUEST));
server.setHandler(context);
其中,keycloak.json文件来自keycloak-clients-installtion,形如
{
"realm": "wildfly",
"auth-server-url": "http://localhost:8080/auth/",
"ssl-required": "external",
"resource": "product-app",
"public-client": true,
"confidential-port": 0
}
keycloak的配置不再赘述。
获取用户权限信息
这块不再举例,自己写个Filter去KeycloakSecurityContext里拿就可以了。
其它问题
1.如何用代码完成在KeyCloak注册和配置过程,实现自动化配置?
KeyCloark有restApi,也有命令行工具。下面是简单暴力的做法,使用命令行
#添加管理员用户
.../bin/add-user-keycloak.sh -r master -u <username> -p <password>
$ kcadm.sh config credentials --server http://localhost:8080/auth --realm master --user admin
$ kcadm.sh create realms -s realm=demorealm -s enabled=true -o
$ CID=$(kcadm.sh create clients -r demorealm -s clientId=my_client -s 'redirectUris=["http://localhost:8980/myapp/*"]' -i)
$ kcadm.sh get clients/$CID/installation/providers/keycloak-oidc-keycloak-json
如下所示,使用windows举例
PS G:\keycloak11\bin> .\kcadm config credentials --server http://localhost:8080/auth --realm master --user admin Logging into http://localhost:8080/auth as user admin of realm master
Enter password: admin
PS G:\keycloak11\bin> .\kcadm create realms -s realm=demorealm -s enabled=true -o
PS G:\keycloak11\bin> .\kcadm create clients -r demorealm -s clientId=my_client -s 'redirectUris=[\"http://localhost:8980/myapp/*\"]' -i > clientid.txt
PS G:\keycloak11\bin> set /p CID=<clientid.txt
PS G:\keycloak11\bin> .\kcadm get http://localhost:8080/auth/admin/realms/demorealm/clients/8aba2b1f-4587-43ba-8f51-d2e75db5f65d/installation/providers/keycloak-oidc-keycloak-json
{
"realm" : "demorealm",
"auth-server-url" : "http://localhost:8080/auth/",
"ssl-required" : "external",
"resource" : "my_client",
"credentials" : {
"secret" : "54b8027b-6d7f-4e2b-9b6a-5c1e85b685fa"
},
"confidential-port" : 0
}
PS G:\keycloak11\bin>
基本操作:
$ kcadm.sh create ENDPOINT [ARGUMENTS]
$ kcadm.sh get ENDPOINT [ARGUMENTS]
$ kcadm.sh update ENDPOINT [ARGUMENTS]
$ kcadm.sh delete ENDPOINT [ARGUMENTS]
ENDPOINT is a target resource URI and can either be absolute (starting with http:
or https:
) or relative, used to compose an absolute URL of the following format:
SERVER_URI/admin/realms/REALM/ENDPOINT
For example, if you authenticate against the server http://localhost:8080/auth and realm is master
, then using users
as ENDPOINT results in the resource URL http://localhost:8080/auth/admin/realms/master/users.
If you set ENDPOINT to clients
, the effective resource URI would be http://localhost:8080/auth/admin/realms/master/clients.
角色和用户的管理等也能用kcadm命令来完成。
2.如何退出
HttpServletRequest.logout()
3.更暴力的接入方式KeyCloak Proxy(已停止维护)
把KeyCloak作为一个proxy来使用,免去修改现有代码。
https://hub.docker.com/r/jboss/keycloak-proxy/ 这里有简单的使用方式说明。这种方式只能代理一个client。
This image is deprecated as the Java based Proxy will be replaced by a new Go based implementation soon.
keycloak-proxy在2018年已停止维护,用Golang实现的继任者louketo-proxy也已在2020年停止更新维护。
官方文档已不推荐使用这种方式,相关文档已移除。
ouketo-proxy停止更新和维护,官网说明:https://www.keycloak.org/2020/08/sunsetting-louketo-project.adoc
官网提供的一种类似替代方案:https://github.com/oauth2-proxy/oauth2-proxy (Golang实现,未验证)
参考
https://www.keycloak.org/docs/latest/securing_apps/#_jetty9_adapter