KEYCLOAK-904 Offline portal example added

This commit is contained in:
mposolda 2015-09-24 11:31:54 +02:00
parent df46a8ea0d
commit 018866aa81
16 changed files with 566 additions and 8 deletions

View file

@ -3,6 +3,7 @@ package org.keycloak.util;
import java.io.IOException;
import org.keycloak.OAuth2Constants;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.representations.RefreshToken;
/**
@ -30,7 +31,7 @@ public class RefreshTokenUtil {
/**
* Return refresh token or offline otkne
* Return refresh token or offline token
*
* @param decodedToken
* @return
@ -39,9 +40,9 @@ public class RefreshTokenUtil {
return JsonSerialization.readValue(decodedToken, RefreshToken.class);
}
private static RefreshToken getRefreshToken(String refreshToken) throws IOException {
byte[] decodedToken = Base64Url.decode(refreshToken);
return getRefreshToken(decodedToken);
public static RefreshToken getRefreshToken(String refreshToken) throws IOException {
byte[] encodedContent = new JWSInput(refreshToken).getContent();
return getRefreshToken(encodedContent);
}
/**

View file

@ -216,7 +216,19 @@ An example for retrieve service account dedicated to the Client Application itse
[http://localhost:8080/service-account-portal](http://localhost:8080/service-account-portal)
Client authentication is done with OAuth2 Client Credentials Grant in out-of-bound request (Not Keycloak login screen displayed)
Client authentication is done with OAuth2 Client Credentials Grant in out-of-bound request (Not Keycloak login screen displayed) .
The example also shows different methods of client authentication. There is ProductSAClientSecretServlet using traditional authentication with clientId and client_secret,
but there is also ProductSAClientSignedJWTServlet using client authentication with JWT signed by client private key.
Step 11: Offline Access Example
===============================
An example for retrieve offline token, which is then saved to the database and can be used by application anytime later. Offline token
is valid even if user is already logged out from SSO. Server restart also won't invalidate offline token. Offline token can be revoked by the user in
account management or by admin in admin console.
[http://localhost:8080/offline-access-portal](http://localhost:8080/offline-access-portal)
Admin Console
==========================

View file

View file

@ -0,0 +1,73 @@
<?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/maven-v4_0_0.xsd">
<parent>
<artifactId>keycloak-examples-demo-parent</artifactId>
<groupId>org.keycloak</groupId>
<version>1.6.0.Final-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>org.keycloak.example.demo</groupId>
<artifactId>offline-access-example</artifactId>
<packaging>war</packaging>
<name>Offline Access Portal</name>
<description/>
<repositories>
<repository>
<id>jboss</id>
<name>jboss repo</name>
<url>http://repository.jboss.org/nexus/content/groups/public/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.jboss.spec.javax.servlet</groupId>
<artifactId>jboss-servlet-api_3.0_spec</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-adapter-spi</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-adapter-core</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>offline-access-portal</finalName>
<plugins>
<plugin>
<groupId>org.jboss.as.plugins</groupId>
<artifactId>jboss-as-maven-plugin</artifactId>
<configuration>
<skip>false</skip>
</configuration>
</plugin>
<plugin>
<groupId>org.wildfly.plugins</groupId>
<artifactId>wildfly-maven-plugin</artifactId>
<configuration>
<skip>false</skip>
</configuration>
</plugin>
</plugins>
</build>
</project>

View file

@ -0,0 +1,226 @@
package org.keycloak.example;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.security.cert.X509Certificate;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.DefaultHttpClient;
import org.keycloak.KeycloakSecurityContext;
import org.keycloak.adapters.AdapterDeploymentContext;
import org.keycloak.adapters.HttpFacade;
import org.keycloak.adapters.KeycloakDeployment;
import org.keycloak.adapters.RefreshableKeycloakSecurityContext;
import org.keycloak.adapters.ServerRequest;
import org.keycloak.constants.ServiceUrlConstants;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.RefreshToken;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.KeycloakUriBuilder;
import org.keycloak.util.RefreshTokenUtil;
import org.keycloak.util.StreamUtil;
import org.keycloak.util.Time;
import org.keycloak.util.UriUtils;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class OfflineAccessPortalServlet extends HttpServlet {
@Override
public void init() throws ServletException {
getServletContext().setAttribute(HttpClient.class.getName(), new DefaultHttpClient());
}
@Override
public void destroy() {
getHttpClient().getConnectionManager().shutdown();
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
if (req.getRequestURI().endsWith("/login")) {
storeToken(req);
req.getRequestDispatcher("/WEB-INF/pages/loginCallback.jsp").forward(req, resp);
return;
}
String refreshToken = RefreshTokenDAO.loadToken();
String refreshTokenInfo;
boolean savedTokenAvailable;
if (refreshToken == null) {
refreshTokenInfo = "No token saved in database. Please login first";
savedTokenAvailable = false;
} else {
RefreshToken refreshTokenDecoded = RefreshTokenUtil.getRefreshToken(refreshToken);
String exp = (refreshTokenDecoded.getExpiration() == 0) ? "NEVER" : Time.toDate(refreshTokenDecoded.getExpiration()).toString();
refreshTokenInfo = String.format("<p>Type: %s</p><p>ID: %s</p><p>Expires: %s</p>", refreshTokenDecoded.getType(), refreshTokenDecoded.getId(), exp);
savedTokenAvailable = true;
}
req.setAttribute("tokenInfo", refreshTokenInfo);
req.setAttribute("savedTokenAvailable", savedTokenAvailable);
String customers;
if (req.getRequestURI().endsWith("/loadCustomers")) {
customers = loadCustomers(req, refreshToken);
} else {
customers = "";
}
req.setAttribute("customers", customers);
req.getRequestDispatcher("/WEB-INF/pages/view.jsp").forward(req, resp);
}
private void storeToken(HttpServletRequest req) throws IOException {
RefreshableKeycloakSecurityContext ctx = (RefreshableKeycloakSecurityContext) req.getAttribute(KeycloakSecurityContext.class.getName());
String refreshToken = ctx.getRefreshToken();
RefreshTokenDAO.saveToken(refreshToken);
RefreshToken refreshTokenDecoded = RefreshTokenUtil.getRefreshToken(refreshToken);
Boolean isOfflineToken = refreshTokenDecoded.getType().equals(RefreshTokenUtil.TOKEN_TYPE_OFFLINE);
req.setAttribute("isOfflineToken", isOfflineToken);
}
private String loadCustomers(HttpServletRequest req, String refreshToken) throws ServletException, IOException {
// Retrieve accessToken first with usage of refresh (offline) token from DB
String accessToken = null;
try {
KeycloakDeployment deployment = getDeployment(req);
AccessTokenResponse response = ServerRequest.invokeRefresh(deployment, refreshToken);
accessToken = response.getToken();
} catch (ServerRequest.HttpFailure failure) {
return "Failed to refresh token. Status from auth-server request: " + failure.getStatus() + ", Error: " + failure.getError();
}
// Load customers now
HttpGet get = new HttpGet(UriUtils.getOrigin(req.getRequestURL().toString()) + "/database/customers");
get.addHeader("Authorization", "Bearer " + accessToken);
HttpResponse response = getHttpClient().execute(get);
InputStream is = response.getEntity().getContent();
try {
if (response.getStatusLine().getStatusCode() != 200) {
return "Error when loading customer. Status: " + response.getStatusLine().getStatusCode() + ", error: " + StreamUtil.readString(is);
} else {
List<String> list = JsonSerialization.readValue(is, TypedList.class);
StringBuilder result = new StringBuilder();
for (String customer : list) {
result.append(customer + "<br />");
}
return result.toString();
}
} finally {
is.close();
}
}
private KeycloakDeployment getDeployment(HttpServletRequest servletRequest) throws ServletException {
// The facade object is needed just if you have relative "auth-server-url" in keycloak.json. Otherwise you can call deploymentContext.resolveDeployment(null)
HttpFacade facade = getFacade(servletRequest);
AdapterDeploymentContext deploymentContext = (AdapterDeploymentContext) getServletContext().getAttribute(AdapterDeploymentContext.class.getName());
if (deploymentContext == null) {
throw new ServletException("AdapterDeploymentContext not set");
}
return deploymentContext.resolveDeployment(facade);
}
// TODO: Merge with facade in ServletOAuthClient and move to some common servlet adapter
private HttpFacade getFacade(final HttpServletRequest servletRequest) {
return new HttpFacade() {
@Override
public Request getRequest() {
return new Request() {
@Override
public String getMethod() {
return servletRequest.getMethod();
}
@Override
public String getURI() {
return servletRequest.getRequestURL().toString();
}
@Override
public boolean isSecure() {
return servletRequest.isSecure();
}
@Override
public String getQueryParamValue(String param) {
return servletRequest.getParameter(param);
}
@Override
public String getFirstParam(String param) {
return servletRequest.getParameter(param);
}
@Override
public Cookie getCookie(String cookieName) {
// not needed
return null;
}
@Override
public String getHeader(String name) {
return servletRequest.getHeader(name);
}
@Override
public List<String> getHeaders(String name) {
// not needed
return null;
}
@Override
public InputStream getInputStream() {
try {
return servletRequest.getInputStream();
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
}
@Override
public String getRemoteAddr() {
return servletRequest.getRemoteAddr();
}
};
}
@Override
public Response getResponse() {
throw new IllegalStateException("Not yet implemented");
}
@Override
public X509Certificate[] getCertificateChain() {
throw new IllegalStateException("Not yet implemented");
}
};
}
private HttpClient getHttpClient() {
return (HttpClient) getServletContext().getAttribute(HttpClient.class.getName());
}
static class TypedList extends ArrayList<String> {
}
}

View file

@ -0,0 +1,27 @@
package org.keycloak.example;
import org.keycloak.constants.ServiceUrlConstants;
import org.keycloak.util.KeycloakUriBuilder;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class OfflineExampleUris {
public static final String LOGIN_CLASSIC = "/offline-access-portal/app/login";
public static final String LOGIN_WITH_OFFLINE_TOKEN = "/offline-access-portal/app/login?scope=offline_access";
public static final String LOAD_CUSTOMERS = "/offline-access-portal/app/loadCustomers";
public static final String ACCOUNT_MGMT = KeycloakUriBuilder.fromUri("/auth").path(ServiceUrlConstants.ACCOUNT_SERVICE_PATH + "/applications")
.queryParam("referrer", "offline-access-portal").build("demo").toString();
public static final String LOGOUT = KeycloakUriBuilder.fromUri("/auth").path(ServiceUrlConstants.TOKEN_SERVICE_LOGOUT_PATH)
.queryParam("redirect_uri", "/offline-access-portal").build("demo").toString();
}

View file

@ -0,0 +1,47 @@
package org.keycloak.example;
import java.io.BufferedWriter;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import org.keycloak.util.StreamUtil;
/**
* Very simple DAO, which stores/loads just one token per whole application into file in tmp directory. Useful just for example purposes.
* In real environment, token should be stored in database.
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class RefreshTokenDAO {
public static final String FILE = System.getProperty("java.io.tmpdir") + "/offline-access-portal";
public static void saveToken(final String token) throws IOException {
PrintWriter writer = null;
try {
writer = new PrintWriter(new BufferedWriter(new FileWriter(FILE)));
writer.print(token);
} finally {
if (writer != null) {
writer.close();
}
}
}
public static String loadToken() throws IOException {
FileInputStream fis = null;
try {
fis = new FileInputStream(FILE);
return StreamUtil.readString(fis);
} catch (FileNotFoundException fnfe) {
return null;
} finally {
if (fis != null) {
fis.close();
}
}
}
}

View file

@ -0,0 +1,10 @@
<jboss-deployment-structure>
<deployment>
<dependencies>
<!-- the Demo code uses classes in these modules. These are optional to import if you are not using
Apache Http Client or the HttpClientBuilder that comes with the adapter core -->
<module name="org.apache.httpcomponents"/>
<module name="org.keycloak.keycloak-adapter-spi"/>
</dependencies>
</deployment>
</jboss-deployment-structure>

View file

@ -0,0 +1,10 @@
{
"realm": "demo",
"resource": "offline-access-portal",
"realm-public-key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB",
"auth-server-url": "/auth",
"ssl-required" : "external",
"credentials": {
"secret": "password"
}
}

View file

@ -0,0 +1,35 @@
<%@ page language="java" contentType="text/html; charset=ISO-8859-1"
pageEncoding="ISO-8859-1" %>
<%@ page session="false" %>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
"http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<title>Offline Access Example</title>
</head>
<body bgcolor="#ffffff">
<h1>Offline Access Example</h1>
<hr />
<p>
Login finished and refresh token saved successfully.
</p>
<p>
<div style="background-color: #ddd; border: 1px solid #ccc; padding: 10px;">
<% if ((Boolean) request.getAttribute("isOfflineToken")) { %>
Token type <b>is</b> offline token! You will be able to load customers even after logout or server restart. Offline token can be revoked in account management or by admin in admin console.
<% } else { %>
Token <b>is not</b> offline token! Once you logout or restart server, token won't be valid anymore and you won't be able to load customers.
<% } %>
</div>
</p>
<p>
<a href="/offline-access-portal/app">Back to home page</a>
</p>
</body>
</html>

View file

@ -0,0 +1,45 @@
<%@ page language="java" contentType="text/html; charset=ISO-8859-1"
pageEncoding="ISO-8859-1" %>
<%@ page import="org.keycloak.example.OfflineExampleUris" %>
<%@ page session="false" %>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
"http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<title>Offline Access Example</title>
</head>
<body bgcolor="#ffffff">
<h1>Offline Access Example</h1>
<hr />
<% if (request.getRemoteUser() == null) { %>
<a href="<%= OfflineExampleUris.LOGIN_CLASSIC %>">Login classic</a> |
<a href="<%= OfflineExampleUris.LOGIN_WITH_OFFLINE_TOKEN %>">Login with offline access</a> |
<% } else { %>
<a href='<%= OfflineExampleUris.LOGOUT %>'>Logout</a> |
<% } %>
<a href='<%= OfflineExampleUris.ACCOUNT_MGMT %>'>Account management</a> |
<% if ((Boolean) request.getAttribute("savedTokenAvailable")) { %>
<a href="<%= OfflineExampleUris.LOAD_CUSTOMERS %>">Load customers with saved token</a> |
<% } %>
<hr />
<h2>Saved Refresh Token Info</h2>
<div style="background-color: #ddd; border: 1px solid #ccc; padding: 10px;">
<%= request.getAttribute("tokenInfo") %>
</div>
<hr />
<h2>Customers</h2>
<div style="background-color: #ddd; border: 1px solid #ccc; padding: 10px;">
<%= request.getAttribute("customers") %>
</div>
</body>
</html>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
version="3.0">
<module-name>offline-access-portal</module-name>
<servlet>
<servlet-name>OfflineAccessPortalServle</servlet-name>
<servlet-class>org.keycloak.example.OfflineAccessPortalServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>OfflineAccessPortalServle</servlet-name>
<url-pattern>/app/*</url-pattern>
</servlet-mapping>
<security-constraint>
<web-resource-collection>
<web-resource-name>User</web-resource-name>
<url-pattern>/app/login/*</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>user</role-name>
</auth-constraint>
</security-constraint>
<!--
<security-constraint>
<web-resource-collection>
<url-pattern>/*</url-pattern>
</web-resource-collection>
<user-data-constraint>
<transport-guarantee>CONFIDENTIAL</transport-guarantee>
</user-data-constraint>
</security-constraint> -->
<login-config>
<auth-method>KEYCLOAK</auth-method>
<realm-name>demo</realm-name>
</login-config>
<security-role>
<role-name>admin</role-name>
</security-role>
<security-role>
<role-name>user</role-name>
</security-role>
</web-app>

View file

@ -0,0 +1,3 @@
<script>
window.location = "/offline-access-portal/app";
</script>

View file

@ -37,6 +37,7 @@
<module>third-party</module>
<module>third-party-cdi</module>
<module>service-account</module>
<module>offline-access-app</module>
</modules>
<profiles>

View file

@ -1,6 +1,9 @@
package org.keycloak.example;
/**
* Client authentication based on JWT signed by client private key .
* See Keycloak documentation and <a href="https://tools.ietf.org/html/rfc7519">specs</a> for more details.
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class ProductSAClientSignedJWTServlet extends ProductServiceAccountServlet {

View file

@ -22,7 +22,7 @@
{ "type" : "password",
"value" : "password" }
],
"realmRoles": [ "user" ],
"realmRoles": [ "user", "offline_access" ],
"clientRoles": {
"account": [ "manage-account" ]
}
@ -37,7 +37,7 @@
{ "type" : "password",
"value" : "password" }
],
"realmRoles": [ "user" ],
"realmRoles": [ "user", "offline_access" ],
"clientRoles": {
"account": [ "manage-account" ]
}
@ -52,7 +52,7 @@
{ "type" : "password",
"value" : "password" }
],
"realmRoles": [ "user" ],
"realmRoles": [ "user", "offline_access" ],
"clientRoles": {
"account": [ "manage-account" ]
}
@ -103,6 +103,10 @@
{
"client": "third-party",
"roles": ["user"]
},
{
"client": "offline-access-portal",
"roles": ["user", "offline_access"]
}
],
"clients": [
@ -190,6 +194,17 @@
"attributes": {
"jwt.credential.certificate": "MIICnTCCAYUCBgFPPLDaTzANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdjbGllbnQxMB4XDTE1MDgxNzE3MjI0N1oXDTI1MDgxNzE3MjQyN1owEjEQMA4GA1UEAwwHY2xpZW50MTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAIUjjgv+V3s96O+Za9002Lp/trtGuHBeaeVL9dFKMKzO2MPqdRmHB4PqNlDdd28Rwf5Xn6iWdFpyUKOnI/yXDLhdcuFpR0sMNK/C9Lt+hSpPFLuzDqgtPgDotlMxiHIWDOZ7g9/gPYNXbNvjv8nSiyqoguoCQiiafW90bPHsiVLdP7ZIUwCcfi1qQm7FhxRJ1NiW5dvUkuCnnWEf0XR+Wzc5eC9EgB0taLFiPsSEIlWMm5xlahYyXkPdNOqZjiRnrTWm5Y4uk8ZcsD/KbPTf/7t7cQXipVaswgjdYi1kK2/zRwOhg1QwWFX/qmvdd+fLxV0R6VqRDhn7Qep2cxwMxLsCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAKE6OA46sf20bz8LZPoiNsqRwBUDkaMGXfnob7s/hJZIIwDEx0IAQ3uKsG7q9wb+aA6s+v7S340zb2k3IxuhFaHaZpAd4CyR5cn1FHylbzoZ7rI/3ASqHDqpljdJaFqPH+m7nZWtyDvtZf+gkZ8OjsndwsSBK1d/jMZPp29qYbl1+XfO7RCp/jDqro/R3saYFaIFiEZPeKn1hUJn6BO48vxH1xspSu9FmlvDOEAOz4AuM58z4zRMP49GcFdCWr1wkonJUHaSptJaQwmBwLFUkCbE5I1ixGMb7mjEud6Y5jhfzJiZMo2U8RfcjNbrN0diZl3jB6LQIwESnhYSghaTjNQ=="
}
},
{
"clientId": "offline-access-portal",
"enabled": true,
"consentRequired": true,
"adminUrl": "/offline-access-portal",
"baseUrl": "/offline-access-portal",
"redirectUris": [
"/offline-access-portal/*"
],
"secret": "password"
}
],
"clientScopeMappings": {