Merge remote-tracking branch 'tmp/master'
This commit is contained in:
commit
d946e74127
40 changed files with 3748 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,7 +1,6 @@
|
|||
_book
|
||||
node_modules
|
||||
|
||||
|
||||
# Intellij
|
||||
###################
|
||||
.idea
|
||||
|
|
9
server_development/README.adoc
Executable file
9
server_development/README.adoc
Executable file
|
@ -0,0 +1,9 @@
|
|||
|
||||
= Server Developer Guide
|
||||
|
||||
image:images/keycloak_logo.png[alt="Keycloak"]
|
||||
|
||||
{{book.project.name}} {{book.project.version}}
|
||||
|
||||
http://www.keycloak.org
|
||||
|
26
server_development/SUMMARY.adoc
Executable file
26
server_development/SUMMARY.adoc
Executable file
|
@ -0,0 +1,26 @@
|
|||
= {{book.title}}
|
||||
|
||||
. link:topics/preface.adoc[Preface]
|
||||
. link:topics/admin-rest-api.adoc[Admin REST API]
|
||||
. link:topics/themes.adoc[Themes]
|
||||
. link:topics/custom-attributes.adoc[Custom User Attributes]
|
||||
{% if book.community %}
|
||||
. link:topics/providers.adoc[Service Provider Interfaces (SPI)]
|
||||
. link:topics/extensions.adoc[Extending Server]
|
||||
. link:topics/auth-spi.adoc[Authentication SPI]
|
||||
. link:topics/events.adoc[Event Listener SPI]
|
||||
{% endif %}
|
||||
. link:topics/user-storage.adoc[User Storage SPI]
|
||||
.. link:topics/user-storage/provider-interfaces.adoc[Provider Interfaces]
|
||||
.. link:topics/user-storage/provider-capability-interfaces.adoc[Provider Capability Interfaces]
|
||||
.. link:topics/user-storage/model-interfaces.adoc[Model Interfaces]
|
||||
.. link:topics/user-storage/packaging.adoc[Packaging and Deployment]
|
||||
.. link:topics/user-storage/simple-example.adoc[Simple Read-Only, Lookup Example]
|
||||
.. link:topics/user-storage/configuration.adoc[Configuration Techniques]
|
||||
.. link:topics/user-storage/registration-query.adoc[Add/Remove User and Query Capability interfaces]
|
||||
.. link:topics/user-storage/augmenting.adoc[Augmenting External Storage]
|
||||
.. link:topics/user-storage/import.adoc[Import Implementation Strategy]
|
||||
.. link:topics/user-storage/cache.adoc[User Caches]
|
||||
.. link:topics/user-storage/javaee.adoc[Leveraging Java EE]
|
||||
.. link:topics/user-storage/rest.adoc[REST Management API]
|
||||
.. link:topics/user-storage/migration.adoc[Migrating from an Earlier User Federation SPI]
|
40
server_development/book-product.json
Executable file
40
server_development/book-product.json
Executable file
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"gitbook": "2.x.x",
|
||||
"structure": {
|
||||
"readme": "README.adoc"
|
||||
},
|
||||
"plugins": [
|
||||
"toggle-chapters",
|
||||
"ungrey",
|
||||
"splitter"
|
||||
],
|
||||
"variables": {
|
||||
"title": "Server Developer Guide",
|
||||
"project": {
|
||||
"name": "Red Hat Single Sign-On",
|
||||
"version": "7.1.0",
|
||||
"doc_base_url": "https://access.redhat.com/documentation/en/red-hat-single-sign-on/",
|
||||
"doc_info_version_url": "7.1-Beta"
|
||||
},
|
||||
"community": false,
|
||||
"product": true,
|
||||
"images": "rhsso-images",
|
||||
"adminguide": {
|
||||
"name": "Server Administration Guide",
|
||||
"link": "/single/server-administration-guide/"
|
||||
},
|
||||
"clientguide": {
|
||||
"name": "Securing Applications and Services Guide",
|
||||
"link": "/single/securing-applications-and-services-guide/"
|
||||
|
||||
},
|
||||
"installguide": {
|
||||
"name": "Server Installation and Configuration Guide",
|
||||
"link": "/single/server-installation-and-configuration-guide/"
|
||||
},
|
||||
"apidocs": {
|
||||
"name": "API Documentation",
|
||||
"link": "/single/api-documentation/"
|
||||
}
|
||||
}
|
||||
}
|
38
server_development/book.json
Executable file
38
server_development/book.json
Executable file
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"gitbook": "2.x.x",
|
||||
"structure": {
|
||||
"readme": "README.adoc"
|
||||
},
|
||||
"plugins": [
|
||||
"toggle-chapters",
|
||||
"ungrey",
|
||||
"splitter"
|
||||
],
|
||||
"variables": {
|
||||
"title": "Server Developer Guide",
|
||||
"project": {
|
||||
"name": "Keycloak",
|
||||
"version": "SNAPSHOT"
|
||||
},
|
||||
"community": true,
|
||||
"product": false,
|
||||
"images": "keycloak-images",
|
||||
"adminguide": {
|
||||
"name": "Server Administration Guide",
|
||||
"link": "https://keycloak.gitbooks.io/server-adminstration-guide/content/"
|
||||
},
|
||||
"clientguide": {
|
||||
"name": "Securing Applications and Services Guide",
|
||||
"link": "https://keycloak.gitbooks.io/securing-client-applications-guide/content/"
|
||||
|
||||
},
|
||||
"installguide": {
|
||||
"name": "Server Installation and Configuration Guide",
|
||||
"link": "https://keycloak.gitbooks.io/server-installation-and-configuration/content/"
|
||||
},
|
||||
"apidocs": {
|
||||
"name": "API Documentation",
|
||||
"link": "http://keycloak.org/docs"
|
||||
}
|
||||
}
|
||||
}
|
7
server_development/build.sh
Executable file
7
server_development/build.sh
Executable file
|
@ -0,0 +1,7 @@
|
|||
#!/bin/bash
|
||||
|
||||
cd $(readlink -f `dirname $0`)
|
||||
|
||||
python gitlab-conversion.py
|
||||
cd target
|
||||
asciidoctor master.adoc
|
69
server_development/buildGuide.sh
Executable file
69
server_development/buildGuide.sh
Executable file
|
@ -0,0 +1,69 @@
|
|||
# Build the guide
|
||||
|
||||
# Find the directory name and full path
|
||||
CURRENT_GUIDE=${PWD##*/}
|
||||
CURRENT_DIRECTORY=$(pwd)
|
||||
|
||||
usage(){
|
||||
cat <<EOM
|
||||
USAGE: $0 [OPTION]
|
||||
|
||||
DESCRIPTION: Build the documentation in this directory.
|
||||
|
||||
OPTIONS:
|
||||
-h Print help.
|
||||
|
||||
EOM
|
||||
}
|
||||
|
||||
while getopts "ht:" c
|
||||
do
|
||||
case "$c" in
|
||||
h) usage
|
||||
exit 1;;
|
||||
\?) echo "Unknown option: -$OPTARG." >&2
|
||||
usage
|
||||
exit 1;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ! -d target ]; then
|
||||
echo "You must run 'python gitlab-conversion.py' to convert the content before you run this script."
|
||||
exit
|
||||
fi
|
||||
|
||||
# Remove the html and build directories and then recreate the html/images/ directory
|
||||
if [ -d target/html ]; then
|
||||
- rm -r target/html/
|
||||
fi
|
||||
if [ -d target/html ]; then
|
||||
rm -r target/html/
|
||||
fi
|
||||
|
||||
mkdir -p html
|
||||
cp -r target/images/ target/html/
|
||||
|
||||
echo ""
|
||||
echo "********************************************"
|
||||
echo " Building $CURRENT_GUIDE "
|
||||
echo "********************************************"
|
||||
echo ""
|
||||
echo "Building an asciidoctor version of the guide"
|
||||
asciidoctor -t -dbook -a toc -o target/html/$CURRENT_GUIDE.html target/master.adoc
|
||||
|
||||
echo ""
|
||||
echo "Building a ccutil version of the guide"
|
||||
ccutil compile --lang en_US --format html-single --main-file target/master.adoc
|
||||
|
||||
cd ..
|
||||
|
||||
echo "View the asciidoctor build here: " file://$CURRENT_DIRECTORY/target/html/$CURRENT_GUIDE.html
|
||||
|
||||
if [ -d $CURRENT_DIRECTORY/build/tmp/en-US/html-single/ ]; then
|
||||
echo "View the ccutil build here: " file://$CURRENT_DIRECTORY/build/tmp/en-US/html-single/index.html
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}Build using ccutil failed!"
|
||||
echo -e "${BLACK}See the log above for details."
|
||||
exit 1
|
||||
fi
|
113
server_development/gitlab-conversion.py
Executable file
113
server_development/gitlab-conversion.py
Executable file
|
@ -0,0 +1,113 @@
|
|||
import sys, os, re, json, shutil, errno
|
||||
|
||||
def transform(root, f, targetdir):
|
||||
full = os.path.join(root, f)
|
||||
input = open(full, 'r').read()
|
||||
dir = os.path.join(targetdir, root)
|
||||
if not os.path.exists(dir):
|
||||
os.makedirs(dir)
|
||||
output = open(os.path.join(dir, f), 'w')
|
||||
input = applyTransformation(input)
|
||||
output.write(input)
|
||||
|
||||
|
||||
def applyTransformation(input):
|
||||
for variable in re.findall(r"\{\{(.*?)\}\}", input):
|
||||
tmp = variable.replace('.', '_')
|
||||
input = input.replace(variable, tmp)
|
||||
input = input.replace('{{', '{').replace('}}', '}')
|
||||
input = re.sub(r"<<fake.+#", "<<", input)
|
||||
for variable in re.findall(r"[ ]*{% if (.*?) %}", input):
|
||||
tmp = variable.replace('.', '_')
|
||||
input = input.replace(variable, tmp)
|
||||
exp = re.compile("[ ]*{% if (.*?) %}(.*?)[ ]*{% endif %}", re.DOTALL)
|
||||
input = re.sub(exp, "ifeval::[{\g<1>}==true]\g<2>endif::[]", input)
|
||||
input = re.sub(r"image:(\.\./)*", "image:", input)
|
||||
input = re.sub(r"image::(\.\./)*", "image::", input)
|
||||
return input
|
||||
|
||||
|
||||
indir = 'topics'
|
||||
targetdir = 'target'
|
||||
if len(sys.argv) > 1:
|
||||
targetdir = sys.argv[1]
|
||||
|
||||
if os.path.exists(targetdir):
|
||||
shutil.rmtree(targetdir)
|
||||
|
||||
if os.path.isdir('images'):
|
||||
shutil.copytree('images',os.path.join(targetdir, 'images'))
|
||||
if os.path.isdir('keycloak-images'):
|
||||
shutil.copytree('keycloak-images',os.path.join(targetdir, 'keycloak-images'))
|
||||
if os.path.isdir('rhsso-images'):
|
||||
shutil.copytree('rhsso-images',os.path.join(targetdir, 'rhsso-images'))
|
||||
|
||||
shutil.copyfile('metadata.ini', os.path.join(targetdir, 'metadata.ini'));
|
||||
shutil.copyfile('master-docinfo.xml', os.path.join(targetdir, 'master-docinfo.xml'));
|
||||
|
||||
tmp = os.path.join(targetdir, 'topics')
|
||||
if not os.path.exists(tmp):
|
||||
os.makedirs(tmp)
|
||||
|
||||
# transform files
|
||||
for root, dirs, filenames in os.walk(indir):
|
||||
for f in filenames:
|
||||
transform(root,f,targetdir)
|
||||
|
||||
# Create master.doc includes
|
||||
input = open('SUMMARY.adoc', 'r').read()
|
||||
output = open(os.path.join(targetdir, 'master.adoc'), 'w')
|
||||
|
||||
output.write("""
|
||||
:toc:
|
||||
:toclevels: 3
|
||||
:numbered:
|
||||
|
||||
include::document-attributes.adoc[]
|
||||
""")
|
||||
|
||||
input = re.sub(r"[ ]*\.+\s*link:(.*)\[(.*)\]", "include::\g<1>[]", input)
|
||||
input = applyTransformation(input)
|
||||
output.write(input)
|
||||
|
||||
# parse book-product.json file and create document attributes
|
||||
with open('book-product.json') as data_file:
|
||||
data = json.load(data_file)
|
||||
|
||||
variables = data['variables']
|
||||
|
||||
def makeAttributes(variables, variable, list):
|
||||
for i in variables.keys():
|
||||
if variable is None:
|
||||
tmp = i
|
||||
else:
|
||||
tmp = variable + '_' + i
|
||||
if isinstance(variables[i],dict):
|
||||
makeAttributes(variables[i], tmp, list)
|
||||
elif isinstance(variables[i],bool):
|
||||
boolval = 'false'
|
||||
if variables[i]:
|
||||
boolval = 'true'
|
||||
list.append({tmp: boolval})
|
||||
else:
|
||||
list.append({tmp: str(variables[i])})
|
||||
|
||||
|
||||
attributeList = []
|
||||
makeAttributes(variables, None, attributeList)
|
||||
|
||||
output = open(os.path.join(targetdir, 'document-attributes.adoc'), 'w')
|
||||
for attribute in attributeList:
|
||||
for k in attribute.keys():
|
||||
output.write(':book_' + k + ": " + attribute[k] + "\n")
|
||||
|
||||
print "Transformation complete!"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
BIN
server_development/images/keycloak_logo.png
Executable file
BIN
server_development/images/keycloak_logo.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
BIN
server_development/images/login-sunrise.png
Normal file
BIN
server_development/images/login-sunrise.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 412 KiB |
Binary file not shown.
After Width: | Height: | Size: 307 KiB |
BIN
server_development/keycloak-images/storage-provider-created.png
Normal file
BIN
server_development/keycloak-images/storage-provider-created.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 326 KiB |
Binary file not shown.
After Width: | Height: | Size: 325 KiB |
BIN
server_development/keycloak-images/user-federation-page.png
Normal file
BIN
server_development/keycloak-images/user-federation-page.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 296 KiB |
12
server_development/master-docinfo.xml
Executable file
12
server_development/master-docinfo.xml
Executable file
|
@ -0,0 +1,12 @@
|
|||
<productname>{book_project_name}</productname>
|
||||
<productnumber>{book_project_doc_info_version_url}</productnumber>
|
||||
<subtitle>For Use with {book_project_name} {book_project_doc_info_version_url}</subtitle>
|
||||
<title>{book_title}</title>
|
||||
<release>{book_project_doc_info_version_url}</release>
|
||||
<abstract>
|
||||
<para>This guides consist of information for developers to customize {book_project_name} {book_project_doc_info_version_url}</para>
|
||||
</abstract>
|
||||
<authorgroup>
|
||||
<orgname>Red Hat Customer Content Services</orgname>
|
||||
</authorgroup>
|
||||
<xi:include href="Common_Content/Legal_Notice.xml" xmlns:xi="http://www.w3.org/2001/XInclude" />
|
20
server_development/metadata.ini
Normal file
20
server_development/metadata.ini
Normal file
|
@ -0,0 +1,20 @@
|
|||
[source]
|
||||
language = en-US
|
||||
type = book
|
||||
markup = asciidoc
|
||||
|
||||
[metadata]
|
||||
title = Server Developer Guide
|
||||
product = Red Hat Single Sign-On
|
||||
version = 7.0
|
||||
edition =
|
||||
subtitle =
|
||||
keywords =
|
||||
abstract =
|
||||
|
||||
[bugs]
|
||||
reporting_url =
|
||||
type =
|
||||
product =
|
||||
component = Documentation
|
||||
|
64
server_development/topics/admin-rest-api.adoc
Normal file
64
server_development/topics/admin-rest-api.adoc
Normal file
|
@ -0,0 +1,64 @@
|
|||
== Admin REST API
|
||||
|
||||
{{book.project.name}} comes with a fully functional Admin REST API with all features provided by the Admin Console.
|
||||
|
||||
To invoke the API you need to obtain an access token with the appropriate permissions. The required permissions are described in
|
||||
{{book.project.doc_base_url}}{{book.project.doc_info_version_url}}{{book.adminguide.link}}[{{book.adminguide.name}}].
|
||||
|
||||
A token can be obtained by enabling authenticating to your application with {{book.project.name}}; see the
|
||||
{{book.project.doc_base_url}}{{book.project.doc_info_version_url}}{{book.clientguide.link}}[{{book.clientguide.name}}]. You can also use direct access grant to obtain an access token.
|
||||
|
||||
For complete documentation see {{book.project.doc_base_url}}{{book.project.doc_info_version_url}}{{book.apidocs.link}}[{{book.apidocs.name}}].
|
||||
|
||||
=== Example using CURL
|
||||
|
||||
Obtain access token for user in the realm `master` with username `admin` and password `password`:
|
||||
[source,bash]
|
||||
----
|
||||
curl \
|
||||
-d "client_id=admin-cli" \
|
||||
-d "username=admin" \
|
||||
-d "password=password" \
|
||||
-d "grant_type=password" \
|
||||
"http://localhost:8080/auth/realms/master/protocol/openid-connect/token"
|
||||
----
|
||||
|
||||
NOTE: By default this token expires in 1 minute
|
||||
|
||||
The result will be a JSON document. To invoke the API you need to extract the value of the `access_token` property. You can then invoke the API by including
|
||||
the value in the `Authorization` header of requests to the API.
|
||||
|
||||
The following example shows how to get the details of the master realm:
|
||||
|
||||
[source,bash]
|
||||
----
|
||||
curl \
|
||||
-H "Authorization: bearer eyJhbGciOiJSUz..." \
|
||||
"http://localhost:8080/auth/admin/realms/master"
|
||||
----
|
||||
|
||||
{% if book.community %}
|
||||
=== Example using Java
|
||||
|
||||
There's a Java client library for the Admin REST API that makes it easy to use from Java. To use it from your application add a dependency on the
|
||||
`keycloak-admin-client` library.
|
||||
|
||||
The following example shows how to use the Java client library to get the details of the master realm:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
import org.keycloak.admin.client.Keycloak;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
...
|
||||
|
||||
Keycloak keycloak = Keycloak.getInstance(
|
||||
"http://localhost:8080/auth",
|
||||
"master",
|
||||
"admin",
|
||||
"password",
|
||||
"admin-cli");
|
||||
RealmRepresentation realm = keycloak.realm("master").toRepresentation();
|
||||
----
|
||||
|
||||
Complete Javadoc for the admin client is available at {{book.project.doc_base_url}}{{book.project.doc_info_version_url}}{{book.apidocs.link}}[{{book.apidocs.name}}].
|
||||
{% endif %}
|
800
server_development/topics/auth-spi.adoc
Executable file
800
server_development/topics/auth-spi.adoc
Executable file
|
@ -0,0 +1,800 @@
|
|||
[[_auth_spi]]
|
||||
== Authentication SPI
|
||||
|
||||
Keycloak comes out of the box with a bunch of different authentication mechanisms: kerberos, password, and otp.
|
||||
These mechanisms may not meet all of your requirements and you may want to plug in your own custom ones.
|
||||
Keycloak provides an authentication SPI that you can use to write new plugins.
|
||||
The admin console supports applying, ordering, and configuring these new mechanisms.
|
||||
|
||||
Keycloak also supports a simple registration form.
|
||||
Different aspects of this form can be enabled and disabled i.e.
|
||||
Recaptcha support can be turned off and on.
|
||||
The same authentication SPI can be used to add another page to the registration flow or reimplement it entirely.
|
||||
There's also an additional fine-grain SPI you can use to add specific validations and user extensions to the built in registration form.
|
||||
|
||||
A required action in Keycloak is an action that a user has to perform after he authenticates.
|
||||
After the action is performed successfully, the user doesn't have to perform the action again.
|
||||
Keycloak comes with some built in required actions like "reset password". This action forces the user to change their password after they have logged in.
|
||||
You can write and plug in your own required actions.
|
||||
|
||||
=== Terms
|
||||
|
||||
To first learn about the Authentication SPI, let's go over some of the terms used to describe it.
|
||||
|
||||
Authentication Flow::
|
||||
A flow is a container for all authentications that must happen during login or registration.
|
||||
If you go to the admin console authentication page, you can view all the defined flows in the system and what authenticators they are made up of.
|
||||
Flows can contain other flows.
|
||||
You can also bind a new different flow for browser login, direct grant access, and registration.
|
||||
|
||||
Authenticator::
|
||||
An authenticator is a pluggable component that hold the logic for performing the authentication or action within a flow.
|
||||
It is usually a singleton.
|
||||
|
||||
Execution::
|
||||
An execution is an object that binds the authenticator to the flow and the authenticator to the configuration of the authenticator.
|
||||
Flows contain execution entries.
|
||||
|
||||
Execution Requirement::
|
||||
Each execution defines how an authenticator behaves in a flow.
|
||||
The requirement defines whether the authenticator is enabled, disabled, optional, required, or an alternative.
|
||||
An alternative requirement means that the authentiactor is optional unless no other alternative authenticator is successful in the flow.
|
||||
For example, cookie authentication, kerberos, and the set of all login forms are all alternative.
|
||||
If one of those is successful, none of the others are executed.
|
||||
|
||||
Authenticator Config::
|
||||
This object defines the configuration for the Authenticator for a specific execution within an authentication flow.
|
||||
Each execution can have a different config.
|
||||
|
||||
Required Action::
|
||||
After authentication completes, the user might have one or more one-time actions he must complete before he is allowed to login.
|
||||
The user might be required to set up an OTP token generator or reset an expired password or even accept a Terms and Conditions document.
|
||||
|
||||
=== Algorithm Overview
|
||||
|
||||
Let's talk about how this all works for browser login.
|
||||
Let's assume the following flows, executions and sub flows.
|
||||
[source]
|
||||
----
|
||||
|
||||
Cookie - ALTERNATIVE
|
||||
Kerberos - ALTERNATIVE
|
||||
Forms Subflow - ALTERNATIVE
|
||||
Username/Password Form - REQUIRED
|
||||
OTP Password Form - OPTIONAL
|
||||
----
|
||||
|
||||
In the top level of the form we have 3 executions of which all are alternatively required.
|
||||
This means that if any of these are successful, then the others do not have to execute.
|
||||
The Username/Password form is not executed if there is an SSO Cookie set or a successful Kerberos login.
|
||||
Let's walk through the steps from when a client first redirects to keycloak to authenticate the user.
|
||||
|
||||
. The OpenID Connect or SAML protocol provider unpacks relevent data, verifies the client and any signatures.
|
||||
It creates a ClientSessionModel.
|
||||
It looks up what the browser flow should be, then starts executing the flow.
|
||||
. The flow looks at the cookie execution and sees that it is an alternative.
|
||||
It loads the cookie provider.
|
||||
It checks to see if the cookie provider requires that a user already be associated with the client session.
|
||||
Cookie provider does not require a user.
|
||||
If it did, the flow would abort and the user would see an error screen.
|
||||
Cookie provider then executes.
|
||||
Its purpose is to see if there is an SSO cookie set.
|
||||
If there is one set, it is validated and the UserSessionModel is verified and associated with the ClientSessionModel.
|
||||
The Cookie provider returns a success() status if the SSO cookie exists and is validated.
|
||||
Since the cookie provider returned success and each execution at this level of the flow is ALTERNATIVE, no other execution is executed and this results in a successful login.
|
||||
If there is no SSO cookie, the cookie provider returns with a status of attempted(). This means there was no error condition, but no success either.
|
||||
The provider tried, but the request just wasn't set up to handle this authenticator.
|
||||
. Next the flow looks at the Kerberos execution.
|
||||
This is also an alternative.
|
||||
The kerberos provider also does not require a user to be already set up and associated with the ClientSessionModel so this provider is executed.
|
||||
Kerberos uses the SPNEGO browser protocol.
|
||||
This requires a series of challenge/responses between the server and client exchanging negotiation headers.
|
||||
The kerberos provider does not see any negotiate header, so it assumes that this is the first interaction between the server and client.
|
||||
It therefore creates an HTTP challenge response to the client and sets a forceChallenge() status.
|
||||
A forceChallenge() means that this HTTP response cannot be ignored by the flow and must be returned to the client.
|
||||
If instead the provider returned a challenge() status, the flow would hold the challenge response until all other alternatives are attempted.
|
||||
So, in this initial phase, the flow would stop and the challenge response would be sent back to the browser.
|
||||
If the browser then responds with a successful negotiate header, the provider associates the user with the ClientSession and the flow ends because the rest of the executions on this level of the flow are all alternatives.
|
||||
Otherwise, again, the kerberos provider sets an attempted() status and the flow continues.
|
||||
. The next execution is a subflow called Forms.
|
||||
The executions for this subflow are loaded and the same processing logic occurs
|
||||
. The first execution in the Forms subflow is the UsernamePassword provider.
|
||||
This provider also does not require for a user to already be associated with the flow.
|
||||
This provider creates challenge HTTP response and sets its status to challenge(). This execution is required, so the flow honors this challenge and sends the HTTP response back to the browser.
|
||||
This response is a rendering of the Username/Password HTML page.
|
||||
The user enters in their username and password and clicks submit.
|
||||
This HTTP request is directed to the UsernamePassword provider.
|
||||
If the user entered an invalid username or password, a new challenge response is created and a status of failureChallenge() is set for this execution.
|
||||
A failureChallenge() means that there is a challenge, but that the flow should log this as an error in the error log.
|
||||
This error log can be used to lock accounts or IP Addresses that have had too many login failures.
|
||||
If the username and password is valid, the provider associated the UserModel with the ClientSessionModel and returns a status of success()
|
||||
. The next execution is the OTP Form.
|
||||
This provider requires that a user has been associated with the flow.
|
||||
This requirement is satisfied because the UsernamePassword provider already associated the user with the flow.
|
||||
Since a user is required for this provider, the provider is also asked if the user is configured to use this provider.
|
||||
If user is not configured, and this execution is required, then the flow will then set up a required action that the user must perform after authentication is complete.
|
||||
For OTP, this means the OTP setup page.
|
||||
If the execution was optional, then this execution is skipped.
|
||||
. After the flow is complete, the authentication processor creates a UserSessionModel and associates it with the ClientSessionModel.
|
||||
It then checks to see if the user is required to complete any required actions before logging in.
|
||||
. First, each required action's evaluateTriggers() method is called.
|
||||
This allows the required action provider to figure out if there is some state that might trigger the action to be fired.
|
||||
For example, if your realm has a password expiration policy, it might be triggered by this method.
|
||||
. Each required action associated with the user that has its requiredActionChallenge() method called.
|
||||
Here the provider sets up an HTTP response which renders the page for the required action.
|
||||
This is done by setting a challenge status.
|
||||
. If the required action is ultimately successful, then the required action is removed from the user's require actions list.
|
||||
. After all required actions have been resolved, the user is finally logged in.
|
||||
|
||||
[[_auth_spi_walkthrough]]
|
||||
=== Authenticator SPI Walk Through
|
||||
|
||||
In this section, we'll take a look at the Authenticator interface.
|
||||
For this, we are going to implement an authenticator that requires that a user enter in the answer to a secret question like "What is your mother's maiden name?". This example is fully implemented and contained in the examples/providers/authenticator directory of the demo distribution of Keycloak.
|
||||
|
||||
The classes you must implement are the org.keycloak.authentication.AuthenticatorFactory and Authenticator interfaces.
|
||||
The Authenticator interface defines the logic.
|
||||
The AuthenticatorFactory is responsible for creating instances of an Authenticator.
|
||||
They both extend a more generic Provider and ProviderFactory set of interfaces that other Keycloak components like User Federation do.
|
||||
|
||||
==== Packaging Classes and Deployment
|
||||
|
||||
You will package your classes within a single jar.
|
||||
This jar must contain a file named `org.keycloak.authentication.AuthenticatorFactory` and must be contained in the `META-INF/services/` directory of your jar.
|
||||
This file must list the fully qualified classname of each AuthenticatorFactory implementation you have in the jar.
|
||||
For example:
|
||||
|
||||
[source]
|
||||
----
|
||||
org.keycloak.examples.authenticator.SecretQuestionAuthenticatorFactory
|
||||
org.keycloak.examples.authenticator.AnotherProviderFactory
|
||||
----
|
||||
|
||||
This services/ file is used by Keycloak to scan the providers it has to load into the system.
|
||||
|
||||
To deploy this jar, just copy it to the standalone/configuration/providers directory.
|
||||
|
||||
==== Implementing an Authenticator
|
||||
|
||||
When implementing the Authenticator interface, the first method that needs to be implemented is the requiresUser() method.
|
||||
For our example, this method must return true as we need to validate the secret question associated with the user.
|
||||
A provider like kerberos would return false from this method as it can resolve a user from the negotiate header.
|
||||
This example, however, is validating a specific credential of a specific user.
|
||||
|
||||
The next method to implement is the configuredFor() method.
|
||||
This method is responsible for determining if the user is configured for this particular authenticator.
|
||||
For this example, we need to check if the answer to the secret question has been set up by the user or not.
|
||||
In our case we are storing this information, hashed, within a UserCredentialValueModel within the UserModel (just like passwords are stored). Here's how we do this very simple check:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Override
|
||||
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
|
||||
return session.users().configuredForCredentialType("secret_question", realm, user);
|
||||
}
|
||||
----
|
||||
|
||||
The configuredForCredentialType() call queries the user to see if it supports that credential type.
|
||||
|
||||
The next method to implement on the Authenticator is setRequiredActions(). If configuredFor() returns false and our example authenticator is required within the flow, this method will be called.
|
||||
It is responsible for registering any required actions that must be performed by the user.
|
||||
In our example, we need to register a required action that will force the user to set up the answer to the secret question.
|
||||
We will implement this required action provider later in this chapter.
|
||||
Here is the implementation of the setRequiredActions() method.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Override
|
||||
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
|
||||
user.addRequiredAction("SECRET_QUESTION_CONFIG");
|
||||
}
|
||||
----
|
||||
|
||||
Now we are getting into the meat of the Authenticator implementation.
|
||||
The next method to implement is authenticate(). This is the initial method the flow invokes when the execution is first visited.
|
||||
What we want is that if a user has answered the secret question already on their browser's machine, then the user doesn't have to answer the question again, making that machine "trusted". The authenticate() method isn't responsible for processing the secret question form.
|
||||
Its sole purpose is to render the page or to continue the flow.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
@Override
|
||||
public void authenticate(AuthenticationFlowContext context) {
|
||||
if (hasCookie(context)) {
|
||||
context.success();
|
||||
return;
|
||||
}
|
||||
Response challenge = loginForm(context).createForm("secret_question.ftl");
|
||||
context.challenge(challenge);
|
||||
}
|
||||
----
|
||||
|
||||
The hasCookie() method checks to see if there is already a cookie set on the browser which indicates that the secret question has already been answered.
|
||||
If that returns true, we just mark this execution's status as SUCCESS using the AuthenticationFlowContext.success() method and returning from the authentication() method.
|
||||
|
||||
If the hasCookie() method returns false, we must return a response that renders the secret question HTML form.
|
||||
AuthenticationFlowContext has a form() method that initializes a Freemarker page builder with appropriate base information needed to build the form.
|
||||
This page builder is called `org.keycloak.login.LoginFormsProvider`.
|
||||
the LoginFormsProvider.createForm() method loads a Freemarker template file from your login theme.
|
||||
Additionally you can call the LoginFormsProvider.setAttribute() method if you want to pass additional information to the Freemarker template.
|
||||
We'll go over this later.
|
||||
|
||||
Calling LoginFormsProvider.createForm() returns a JAX-RS Response object.
|
||||
We then call AuthenticationFlowContext.challenge() passing in this response.
|
||||
This sets the status of the execution as CHALLENGE and if the execution is Required, this JAX-RS Response object will be sent to the browser.
|
||||
|
||||
So, the HTML page asking for the answer to a secret question is displayed to the user and the user enteres in the answer and clicks submit.
|
||||
The action URL of the HTML form will send an HTTP request to the flow.
|
||||
The flow will end up invoking the action() method of our Authenticator implementation.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
@Override
|
||||
public void action(AuthenticationFlowContext context) {
|
||||
boolean validated = validateAnswer(context);
|
||||
if (!validated) {
|
||||
Response challenge = context.form()
|
||||
.setError("badSecret")
|
||||
.createForm("secret-question.ftl");
|
||||
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challenge);
|
||||
return;
|
||||
}
|
||||
setCookie(context);
|
||||
context.success();
|
||||
}
|
||||
----
|
||||
|
||||
If the answer is not valid, we rebuild the HTML Form with an additional error message.
|
||||
We then call AuthenticationFlowContext.failureChallenge() passing in the reason for the value and the JAX-RS response.
|
||||
failureChallenge() works the same as challenge(), but it also records the failure so it can be analyzed by any attack detection service.
|
||||
|
||||
If validation is successful, then we set a cookie to remember that the secret question has been answered and we call AuthenticationFlowContext.success().
|
||||
|
||||
The last thing I want to go over is the setCookie() method.
|
||||
This is an example of providing configuration for the Authenticator.
|
||||
In this case we want the max age of the cookie to be configurable.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
protected void setCookie(AuthenticationFlowContext context) {
|
||||
AuthenticatorConfigModel config = context.getAuthenticatorConfig();
|
||||
int maxCookieAge = 60 * 60 * 24 * 30; // 30 days
|
||||
if (config != null) {
|
||||
maxCookieAge = Integer.valueOf(config.getConfig().get("cookie.max.age"));
|
||||
|
||||
}
|
||||
... set the cookie ...
|
||||
}
|
||||
----
|
||||
|
||||
We obtain an AuthenticatorConfigModel from the AuthenticationFlowContext.getAuthenticatorConfig() method.
|
||||
If configuration exists we pull the max age config out of it.
|
||||
We will see how we can define what should be configured when we talk about the AuthenticatorFactory implementation.
|
||||
The config values can be defined within the admin console if you set up config definitions in your AuthenticatorFactory implementation.
|
||||
|
||||
==== Implementing an AuthenticatorFactory
|
||||
|
||||
The next step in this process is to implement an AuthenticatorFactory.
|
||||
This factory is responsible for instantiating an Authenticator.
|
||||
It also provides deployment and configuration metadata about the Authenticator.
|
||||
|
||||
The getId() method is just the unique name of the component.
|
||||
The create() method is called by the runtime to allocate and process the Authenticator.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
public class SecretQuestionAuthenticatorFactory implements AuthenticatorFactory, ConfigurableAuthenticatorFactory {
|
||||
|
||||
public static final String PROVIDER_ID = "secret-question-authenticator";
|
||||
private static final SecretQuestionAuthenticator SINGLETON = new SecretQuestionAuthenticator();
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Authenticator create(KeycloakSession session) {
|
||||
return SINGLETON;
|
||||
}
|
||||
----
|
||||
|
||||
The next thing the factory is responsible for is specify the allowed requirement switches.
|
||||
While there are four different requirement types: ALTERNATIVE, REQUIRED, OPTIONAL, DISABLED, AuthenticatorFactory implementations can limit which requirement options are shown in the admin console when defining a flow.
|
||||
In our example, we're going to limit our requirement options to REQUIRED and DISABLED.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
|
||||
AuthenticationExecutionModel.Requirement.REQUIRED,
|
||||
AuthenticationExecutionModel.Requirement.DISABLED
|
||||
};
|
||||
@Override
|
||||
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
|
||||
return REQUIREMENT_CHOICES;
|
||||
}
|
||||
----
|
||||
|
||||
The AuthenticatorFactory.isUserSetupAllowed() is a flag that tells the flow manager whether or not Authenticator.setRequiredActions() method will be called.
|
||||
If an Authenticator is not configured for a user, the flow manager checks isUserSetupAllowed(). If it is false, then the flow aborts with an error.
|
||||
If it returns true, then the flow manager will invoke Authenticator.setRequiredActions().
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
@Override
|
||||
public boolean isUserSetupAllowed() {
|
||||
return true;
|
||||
}
|
||||
----
|
||||
|
||||
The next few methods define how the Authenticator can be configured.
|
||||
The isConfigurable() method is a flag which specifies to the admin console on whether the Authenticator can be configured within a flow.
|
||||
The getConfigProperties() method returns a list of ProviderConfigProperty objects.
|
||||
These objects define a specific configuration attribute.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
@Override
|
||||
public List<ProviderConfigProperty> getConfigProperties() {
|
||||
return configProperties;
|
||||
}
|
||||
|
||||
private static final List<ProviderConfigProperty> configProperties = new ArrayList<ProviderConfigProperty>();
|
||||
|
||||
static {
|
||||
ProviderConfigProperty property;
|
||||
property = new ProviderConfigProperty();
|
||||
property.setName("cookie.max.age");
|
||||
property.setLabel("Cookie Max Age");
|
||||
property.setType(ProviderConfigProperty.STRING_TYPE);
|
||||
property.setHelpText("Max age in seconds of the SECRET_QUESTION_COOKIE.");
|
||||
configProperties.add(property);
|
||||
}
|
||||
----
|
||||
|
||||
Each ProviderConfigProperty defines the name of the config property.
|
||||
This is the key used in the config map stored in AuthenticatorConfigModel.
|
||||
The label defines how the config option will be displayed in the admin console.
|
||||
The type defines if it is a String, Boolean, or other type.
|
||||
The admin console will display different UI inputs depending on the type.
|
||||
The help text is what will be shown in the tooltip for the config attribute in the admin console.
|
||||
Read the javadoc of ProviderConfigProperty for more detail.
|
||||
|
||||
The rest of the methods are for the admin console.
|
||||
getHelpText() is the tooltip text that will be shown when you are picking the Authenticator you want to bind to an execution.
|
||||
getDisplayType() is what text that will be shown in the admin console when listing the Authenticator.
|
||||
getReferenceCategory() is just a category the Authenticator belongs to.
|
||||
|
||||
==== Adding Authenticator Form
|
||||
|
||||
Keycloak comes with a Freemarker <<fake/../themes.adoc#_themes,theme and template engine>>.
|
||||
The createForm() method you called within authenticate() of your Authenticator class, builds an HTML page from a file within your login theme: secret-question.ftl.
|
||||
This file should be placed in the login theme with all the other .ftl files you see for login.
|
||||
|
||||
Let's take a bigger look at secret-question.ftl Here's a small code snippet:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
<form id="kc-totp-login-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<div class="${properties.kcLabelWrapperClass!}">
|
||||
<label for="totp" class="${properties.kcLabelClass!}">${msg("loginSecretQuestion")}</label>
|
||||
</div>
|
||||
|
||||
<div class="${properties.kcInputWrapperClass!}">
|
||||
<input id="totp" name="secret_answer" type="text" class="${properties.kcInputClass!}" />
|
||||
</div>
|
||||
</div>
|
||||
----
|
||||
|
||||
Any piece of text enclosed in `${}` corresponds to an attribute or template funtion.
|
||||
If you see the form's action, you see it points to `${url.loginAction}`.
|
||||
This value is automatically generated when you invoke the AuthenticationFlowContext.form() method.
|
||||
You can also obtain this value by calling the AuthenticationFlowContext.getActionURL() method in Java code.
|
||||
|
||||
You'll also see `${properties.someValue}`.
|
||||
These correspond to properties defined in your theme.properties file of our theme.
|
||||
`${msg("someValue")}` corresponds to the internationalized message bundles (.properties files) included with the login theme messages/ directory.
|
||||
If you're just using english, you can just add the value of the `loginSecretQuestion` value.
|
||||
This should be the question you want to ask the user.
|
||||
|
||||
When you call AuthenticationFlowContext.form() this gives you a LoginFormsProvider instance.
|
||||
If you called, `LoginFormsProvider.setAttribute("foo", "bar")`, the value of "foo" would be available for reference in your form as `${foo}`.
|
||||
The value of an attribute can be any Java bean as well.
|
||||
|
||||
[[_adding_authenticator]]
|
||||
==== Adding Authenticator to a Flow
|
||||
|
||||
Adding an Authenticator to a flow must be done in the admin console.
|
||||
If you go to the Authentication menu item and go to the Flow tab, you will be able to view the currently defined flows.
|
||||
You cannot modify an built in flows, so, to add the Authenticator we've created you have to copy an existing flow or create your own.
|
||||
I'm hoping the UI is intuitive enough so that you can figure out for yourself how to create a flow and add the Authenticator.
|
||||
|
||||
After you've created your flow, you have to bind it to the login action you want to bind it to.
|
||||
If you go to the Authentication menu and go to the Bindings tab you will see options to bind a flow to the browser, registration, or direct grant flow.
|
||||
|
||||
=== Required Action Walkthrough
|
||||
|
||||
In this section we will discuss how to define a required action.
|
||||
In the Authenticator section you may have wondered, "How will we get the user's answer to the secret question entered into the system?". As we showed in the example, if the answer is not set up, a required action will be triggered.
|
||||
This section discusses how to implement the required action for the Secret Question Authenticator.
|
||||
|
||||
==== Packaging Classes and Deployment
|
||||
|
||||
You will package your classes within a single jar.
|
||||
This jar does not have to be separate from other provider classes but it must contain a file named `org.keycloak.authentication.RequiredActionFactory` and must be contained in the `META-INF/services/` directory of your jar.
|
||||
This file must list the fully qualified classname of each RequiredActionFactory implementation you have in the jar.
|
||||
For example:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
org.keycloak.examples.authenticator.SecretQuestionRequiredActionFactory
|
||||
----
|
||||
|
||||
This services/ file is used by Keycloak to scan the providers it has to load into the system.
|
||||
|
||||
To deploy this jar, just copy it to the standalone/configuration/providers directory.
|
||||
|
||||
==== Implement the RequiredActionProvider
|
||||
|
||||
Required actions must first implement the RequiredActionProvider interface.
|
||||
The RequiredActionProvider.requiredActionChallenge() is the initial call by the flow manager into the required action.
|
||||
This method is responsible for rendering the HTML form that will drive the required action.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
@Override
|
||||
public void requiredActionChallenge(RequiredActionContext context) {
|
||||
Response challenge = context.form().createForm("secret_question_config.ftl");
|
||||
context.challenge(challenge);
|
||||
|
||||
}
|
||||
----
|
||||
|
||||
You see that RequiredActionContext has similar methods to AuthenticationFlowContext.
|
||||
The form() method allows you to render the page from a Freemarker template.
|
||||
The action URL is preset by the call to this form() method.
|
||||
You just need to reference it within your HTML form.
|
||||
I'll show you this later.
|
||||
|
||||
The challenge() method notifies the flow manager that a required action must be executed.
|
||||
|
||||
The next method is responsible for processing input from the HTML form of the required action.
|
||||
The action URL of the form will be routed to the RequiredActionProvider.processAction() method
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
@Override
|
||||
public void processAction(RequiredActionContext context) {
|
||||
String answer = (context.getHttpRequest().getDecodedFormParameters().getFirst("answer"));
|
||||
UserCredentialValueModel model = new UserCredentialValueModel();
|
||||
model.setValue(answer);
|
||||
model.setType(SecretQuestionAuthenticator.CREDENTIAL_TYPE);
|
||||
context.getUser().updateCredentialDirectly(model);
|
||||
context.success();
|
||||
}
|
||||
----
|
||||
|
||||
The answer is pulled out of the form post.
|
||||
A UserCredentialValueModel is created and the type and value of the credential are set.
|
||||
Then UserModel.updateCredentialDirectly() is invoked.
|
||||
Finally, RequiredActionContext.success() notifies the container that the required action was successful.
|
||||
|
||||
==== Implement the RequiredActionFactory
|
||||
|
||||
This class is really simple.
|
||||
It is just responsible for creating the required actin provider instance.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
public class SecretQuestionRequiredActionFactory implements RequiredActionFactory {
|
||||
|
||||
private static final SecretQuestionRequiredAction SINGLETON = new SecretQuestionRequiredAction();
|
||||
|
||||
@Override
|
||||
public RequiredActionProvider create(KeycloakSession session) {
|
||||
return SINGLETON;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return SecretQuestionRequiredAction.PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayText() {
|
||||
return "Secret Question";
|
||||
}
|
||||
----
|
||||
|
||||
The getDisplayText() method is just for the admin console when it wants to display a friendly name for the required action.
|
||||
|
||||
==== Enable Required Action
|
||||
|
||||
The final thing you have to do is go into the admin console.
|
||||
Click on the Authentication left menu.
|
||||
Click on the Required Actions tab.
|
||||
Click on the Register button and choose your new Required Action.
|
||||
Your new required action should now be displayed and enabled in the required actions list.
|
||||
|
||||
=== Modifying/Extending the Registration Form
|
||||
|
||||
It is entirely possible for you to implement your own flow with a set of Authenticators to totally change how regisration is done in Keycloak.
|
||||
But what you'll usually want to do is just add a little bit of validation to the out of the box registration page.
|
||||
An additional SPI was created to be able to do this.
|
||||
It basically allows you to add validation of form elements on the page as well as to initialize UserModel attributes and data after the user has been registered.
|
||||
We'll look at both the implementation of the user profile registration processing as well as the registration Google Recaptcha plugin.
|
||||
|
||||
==== Implementation FormAction Interface
|
||||
|
||||
The core interface you have to implement is the FormAction interface.
|
||||
A FormAction is responsible for rendering and processing a portion of the page.
|
||||
Rendering is done in the buildPage() method, validation is done in the validate() method, post validation operations are done in success(). Let's first take a look at buildPage() method of the Recaptcha plugin.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
@Override
|
||||
public void buildPage(FormContext context, LoginFormsProvider form) {
|
||||
AuthenticatorConfigModel captchaConfig = context.getAuthenticatorConfig();
|
||||
if (captchaConfig == null || captchaConfig.getConfig() == null
|
||||
|| captchaConfig.getConfig().get(SITE_KEY) == null
|
||||
|| captchaConfig.getConfig().get(SITE_SECRET) == null
|
||||
) {
|
||||
form.addError(new FormMessage(null, Messages.RECAPTCHA_NOT_CONFIGURED));
|
||||
return;
|
||||
}
|
||||
String siteKey = captchaConfig.getConfig().get(SITE_KEY);
|
||||
form.setAttribute("recaptchaRequired", true);
|
||||
form.setAttribute("recaptchaSiteKey", siteKey);
|
||||
form.addScript("https://www.google.com/recaptcha/api.js");
|
||||
}
|
||||
----
|
||||
|
||||
The Recaptcha buildPage() method is a callback by the form flow to help render the page.
|
||||
It receives a form parameter which is a LoginFormsProvider.
|
||||
You can add additional attributes to the form provider so that they can be displayed in the HTML page generated by the registration Freemarker template.
|
||||
|
||||
The code above is from the registration recaptcha plugin.
|
||||
Recaptcha requires some specific settings that must be obtained from configuration.
|
||||
FormActions are configured in the exact same was as Authenticators are.
|
||||
In this example, we pull the Google Recaptcha site key from configuration and add it as an attribute to the form provider.
|
||||
Our regstration template file can read this attribute now.
|
||||
|
||||
Recaptcha also has the requirement of loading a javascript script.
|
||||
You can do this by calling LoginFormsProvider.addScript() passing in the URL.
|
||||
|
||||
For user profile processing, there is no additional information that it needs to add to the form, so its buildPage() method is empty.
|
||||
|
||||
The next meaty part of this interface is the validate() method.
|
||||
This is called immediately upon receiving a form post.
|
||||
Let's look at the Recaptcha's plugin first.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
@Override
|
||||
public void validate(ValidationContext context) {
|
||||
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
|
||||
List<FormMessage> errors = new ArrayList<>();
|
||||
boolean success = false;
|
||||
|
||||
String captcha = formData.getFirst(G_RECAPTCHA_RESPONSE);
|
||||
if (!Validation.isBlank(captcha)) {
|
||||
AuthenticatorConfigModel captchaConfig = context.getAuthenticatorConfig();
|
||||
String secret = captchaConfig.getConfig().get(SITE_SECRET);
|
||||
|
||||
success = validateRecaptcha(context, success, captcha, secret);
|
||||
}
|
||||
if (success) {
|
||||
context.success();
|
||||
} else {
|
||||
errors.add(new FormMessage(null, Messages.RECAPTCHA_FAILED));
|
||||
formData.remove(G_RECAPTCHA_RESPONSE);
|
||||
context.validationError(formData, errors);
|
||||
return;
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
Here we obtain the form data that the Recaptcha widget adds to the form.
|
||||
We obtain the Recaptcha secret key from configuration.
|
||||
We then validate the recaptcha.
|
||||
If successful, ValidationContext.success() is called.
|
||||
If not, we invoke ValidationContext.validationError() passing in the formData (so the user doesn't have to re-enter data), we also specify an error message we want displayed.
|
||||
The error message must point to a message bundle property in the internationalized message bundles.
|
||||
For other registration extensions validate() might be validating the format of a form element, i.e.
|
||||
an alternative email attribute.
|
||||
|
||||
Let's also look at the user profile plugin that is used to validate email address and other user information when registering.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
@Override
|
||||
public void validate(ValidationContext context) {
|
||||
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
|
||||
List<FormMessage> errors = new ArrayList<>();
|
||||
|
||||
String eventError = Errors.INVALID_REGISTRATION;
|
||||
|
||||
if (Validation.isBlank(formData.getFirst((RegistrationPage.FIELD_FIRST_NAME)))) {
|
||||
errors.add(new FormMessage(RegistrationPage.FIELD_FIRST_NAME, Messages.MISSING_FIRST_NAME));
|
||||
}
|
||||
|
||||
if (Validation.isBlank(formData.getFirst((RegistrationPage.FIELD_LAST_NAME)))) {
|
||||
errors.add(new FormMessage(RegistrationPage.FIELD_LAST_NAME, Messages.MISSING_LAST_NAME));
|
||||
}
|
||||
|
||||
String email = formData.getFirst(Validation.FIELD_EMAIL);
|
||||
if (Validation.isBlank(email)) {
|
||||
errors.add(new FormMessage(RegistrationPage.FIELD_EMAIL, Messages.MISSING_EMAIL));
|
||||
} else if (!Validation.isEmailValid(email)) {
|
||||
formData.remove(Validation.FIELD_EMAIL);
|
||||
errors.add(new FormMessage(RegistrationPage.FIELD_EMAIL, Messages.INVALID_EMAIL));
|
||||
}
|
||||
|
||||
if (context.getSession().users().getUserByEmail(email, context.getRealm()) != null) {
|
||||
formData.remove(Validation.FIELD_EMAIL);
|
||||
errors.add(new FormMessage(RegistrationPage.FIELD_EMAIL, Messages.EMAIL_EXISTS));
|
||||
}
|
||||
|
||||
if (errors.size() > 0) {
|
||||
context.validationError(formData, errors);
|
||||
return;
|
||||
|
||||
} else {
|
||||
context.success();
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
As you can see, this validate() method of user profile processing makes sure that the email, first, and last name are filled in in the form.
|
||||
It also makes sure that email is in the right format.
|
||||
If any of these validations fail, an error message is queued up for rendering.
|
||||
Any fields in error are removed from the form data.
|
||||
Error messages are represented by the FormMessage class.
|
||||
The first parameter of the constructor of this class takes the HTML element id.
|
||||
The input in error will be highlighted when the form is re-rendered.
|
||||
The second parameter is a message reference id.
|
||||
This id must correspond to a property in one of the localized message bundle files.
|
||||
in the theme.
|
||||
|
||||
After all validations have been processed then, the form flow then invokes the FormAction.success() method.
|
||||
For recaptcha this is a no-op, so we won't go over it.
|
||||
For user profile processing, this method fills in values in the registered user.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
@Override
|
||||
public void success(FormContext context) {
|
||||
UserModel user = context.getUser();
|
||||
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
|
||||
user.setFirstName(formData.getFirst(RegistrationPage.FIELD_FIRST_NAME));
|
||||
user.setLastName(formData.getFirst(RegistrationPage.FIELD_LAST_NAME));
|
||||
user.setEmail(formData.getFirst(RegistrationPage.FIELD_EMAIL));
|
||||
}
|
||||
----
|
||||
|
||||
Pretty simple implementation.
|
||||
The UserModel of the newly registered user is obtained from the FormContext.
|
||||
The appropriate methods are called to initialize UserModel data.
|
||||
|
||||
Finally, you are also required to define a FormActionFactory class.
|
||||
This class is implemented similarly to AuthenticatorFactory, so we won't go over it.
|
||||
|
||||
==== Packaging the Action
|
||||
|
||||
You will package your classes within a single jar.
|
||||
This jar must contain a file named `org.keycloak.authentication.FormActionFactory` and must be contained in the `META-INF/services/` directory of your jar.
|
||||
This file must list the fully qualified classname of each FormActionFactory implementation you have in the jar.
|
||||
For example:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
org.keycloak.authentication.forms.RegistrationProfile
|
||||
org.keycloak.authentication.forms.RegistrationRecaptcha
|
||||
----
|
||||
|
||||
This services/ file is used by Keycloak to scan the providers it has to load into the system.
|
||||
|
||||
To deploy this jar, just copy it to the standalone/configuration/providers directory.
|
||||
|
||||
==== Adding FormAction to the Registration Flow
|
||||
|
||||
Adding an FormAction to a registration page flow must be done in the admin console.
|
||||
If you go to the Authentication menu item and go to the Flow tab, you will be able to view the currently defined flows.
|
||||
You cannot modify an built in flows, so, to add the Authenticator we've created you have to copy an existing flow or create your own.
|
||||
I'm hoping the UI is intuitive enough so that you can figure out for yourself how to create a flow and add the FormAction.
|
||||
|
||||
Basically you'll have to copy the registration flow.
|
||||
Then click Actions menu to the right of the Registration Form, and pick "Add Execution" to add a new execution.
|
||||
You'll pick the FormAction from the selection list.
|
||||
Make sure your FormAction comes after "Registration User Creation" by using the down errors to move it if your FormAction isn't already listed after "Registration User Creation". You want your FormAction to come after user creation because the success() method of Regsitration User Creation is responsible for creating the new UserModel.
|
||||
|
||||
After you've created your flow, you have to bind it to registration.
|
||||
If you go to the Authentication menu and go to the Bindings tab you will see options to bind a flow to the browser, registration, or direct grant flow.
|
||||
|
||||
=== Modifying Forgot Password/Credential Flow
|
||||
|
||||
Keycloak also has a specific authentication flow for forgot password, or rather credential reset initiated by a user.
|
||||
If you go to the admin console flows page, there is a "reset credentials" flow.
|
||||
By default, Keycloak asks for the email or username of the user and sends an email to them.
|
||||
If the user clicks on the link, then they are able to reset both their password and OTP (if an OTP has been set up). You can disable automatic OTP reset by disabling the "Reset OTP" authenticator in the flow.
|
||||
|
||||
You can add additional functionality to this flow as well.
|
||||
For example, many deployments would like for the user to answer one or more secret questions in additional to sending an email with a link.
|
||||
You could expand on the secret question example that comes with the distro and incorporate it into the reset credential flow.
|
||||
|
||||
One thing to note if you are extending the reset credentials flow.
|
||||
The first "authenticator" is just a page to obtain the username or email.
|
||||
If the username or email exists, then the AuthenticationFlowContext.getUser() will return the located user.
|
||||
Otherwise this will be null.
|
||||
This form *WILL NOT* re-ask the user to enter in an email or username if the previous email or username did not exist.
|
||||
You need to prevent attackers from being able to guess valid users.
|
||||
So, if AuthenticationFlowContext.getUser() returns null, you should proceed with the flow to make it look like a valid user was selected.
|
||||
I suggest that if you want to add secret questions to this flow, you should ask these questions after the email is sent.
|
||||
In other words, add your custom authenticator after the "Send Reset Email" authenticator.
|
||||
|
||||
=== Modifying First Broker Login Flow
|
||||
|
||||
First Broker Login flow is used during first login with some identity provider.
|
||||
Term `First Login` means that there is not yet existing {{book.project.name}} account linked with the particular authenticated identity provider account.
|
||||
For more details about this flow see the `Identity Brokering` chapter in link:{{book.project.doc_base_url}}{{book.project.doc_info_version_url}}{{book.adminguide.link}}[{{book.adminguide.name}}] .
|
||||
|
||||
[[_client_authentication]]
|
||||
=== Authentication of clients
|
||||
|
||||
{{book.project.name}} actually supports pluggable authentication for http://openid.net/specs/openid-connect-core-1_0.html[OpenID Connect] client applications.
|
||||
Authentication of client (application) is used under the hood by the {{book.project.name}} adapter during sending any backchannel requests
|
||||
to the {{book.project.name}} server (like the request for exchange code to access token after successful authentication or request to refresh token).
|
||||
But the client authentication can be also used directly by you during `Direct Access grants` (represented by OAuth2 `Resource Owner Password Credentials Flow`)
|
||||
or during `Service account` authentication (represented by OAuth2 `Client Credentials Flow`).
|
||||
|
||||
For more details about {{book.project.name}} adapter and OAuth2 flows see link:{{book.project.doc_base_url}}{{book.project.doc_info_version_url}}{{book.clientguide.link}}[{{book.clientguide.name}}].
|
||||
|
||||
==== Default implementations
|
||||
|
||||
Actually Keycloak has 2 builtin implementations of client authentication:
|
||||
|
||||
Traditional authentication with client_id and client_secret::
|
||||
This is default mechanism mentioned in the http://openid.net/specs/openid-connect-core-1_0.html[OpenID Connect] or http://tools.ietf.org/html/rfc6749[OAuth2] specification and Keycloak supports it since it's early days.
|
||||
The public client needs to include `client_id` parameter with it's ID in the POST request (so it's defacto not authenticated) and the confidential client needs to include `Authorization: Basic` header with the clientId and clientSecret used as username and password.
|
||||
|
||||
Authentication with signed JWT::
|
||||
This is based on the https://tools.ietf.org/html/rfc7523[JWT Bearer Token Profiles for OAuth 2.0] specification.
|
||||
The client/adapter generates the https://tools.ietf.org/html/rfc7519[JWT] and signs it with his private key.
|
||||
The Keycloak then verifies the signed JWT with the client's public key and authenticates client based on it.
|
||||
|
||||
See the demo example and especially the `examples/preconfigured-demo/product-app` for the example application showing
|
||||
the application using client authentication with signed JWT.
|
||||
|
||||
==== Implement your own client authenticator
|
||||
|
||||
For plug your own client authenticator, you need to implement few interfaces on both client (adapter) and server side.
|
||||
|
||||
Client side::
|
||||
Here you need to implement `org.keycloak.adapters.authentication.ClientCredentialsProvider` and put the implementation either to:
|
||||
|
||||
* your WAR file into WEB-INF/classes . But in this case, the implementation can be used just for this single WAR application
|
||||
* Some JAR file, which will be added into WEB-INF/lib of your WAR
|
||||
* Some JAR file, which will be used as jboss module and configured in jboss-deployment-structure.xml of your WAR. In all cases, you also need to create the file `META-INF/services/org.keycloak.adapters.authentication.ClientCredentialsProvider` either in the WAR or in your JAR.
|
||||
|
||||
Server side::
|
||||
Here you need to implement `org.keycloak.authentication.ClientAuthenticatorFactory` and `org.keycloak.authentication.ClientAuthenticator` . You also need to add the file `META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory` with the name of the implementation classes.
|
||||
See <<_auth_spi_walkthrough,authenticators>> for more details.
|
48
server_development/topics/custom-attributes.adoc
Executable file
48
server_development/topics/custom-attributes.adoc
Executable file
|
@ -0,0 +1,48 @@
|
|||
== Custom User Attributes
|
||||
|
||||
You can add custom user attributes to the registration page and account management console with a custom theme. This chapter describes how to add attributes
|
||||
to a custom theme, but you should refer to the <<fake/../themes.adoc#_themes,Themes>> chapter on how to create a custom theme.
|
||||
|
||||
=== Registration Page
|
||||
|
||||
To be able to enter custom attributes in the registration page copy the template `themes/base/login/register.ftl` to the login type of your custom theme. Then
|
||||
open the copy in an editor.
|
||||
|
||||
As an example to add a mobile number to the registration page add the following snippet to the form:
|
||||
|
||||
[source,html]
|
||||
----
|
||||
<div class="form-group">
|
||||
<div class="${properties.kcLabelWrapperClass!}">
|
||||
<label for="user.attributes.mobile" class="${properties.kcLabelClass!}">Mobile number</label>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-10 col-md-10">
|
||||
<input type="text" class="${properties.kcInputClass!}" id="user.attributes.mobile" name="user.attributes.mobile"/>
|
||||
</div>
|
||||
</div>
|
||||
----
|
||||
|
||||
To see the changes make sure your realm is using your custom theme for the login theme and open the registration page.
|
||||
|
||||
=== Account Management Console
|
||||
|
||||
To be able to manage custom attributes in the user profile page in the account management console copy the template `themes/base/account/account.flt` to the
|
||||
account type of your custom theme. Then open the copy in an editor.
|
||||
|
||||
As an example to add a mobile number to the account page add the following snippet to the form:
|
||||
|
||||
[source,html]
|
||||
----
|
||||
<div class="form-group">
|
||||
<div class="col-sm-2 col-md-2">
|
||||
<label for="user.attributes.mobile" class="control-label">Mobile number</label>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-10 col-md-10">
|
||||
<input type="text" class="form-control" id="user.attributes.mobile" name="user.attributes.mobile" value="${(account.attributes.mobile!'')?html}"/>
|
||||
</div>
|
||||
</div>
|
||||
----
|
||||
|
||||
To see the changes make sure your realm is using your custom theme for the account theme and open the user profile page in the account management console.
|
8
server_development/topics/events.adoc
Executable file
8
server_development/topics/events.adoc
Executable file
|
@ -0,0 +1,8 @@
|
|||
[[_events]]
|
||||
== Event Listener SPI
|
||||
|
||||
Writing a Event Listener Provider starts by implementing the `EventListenerProvider` and `EventListenerProviderFactory` interfaces. Please see the Javadoc
|
||||
and examples for complete details on how to do this.
|
||||
|
||||
For details on how to package and deploy a custom provider refer to the <<providers.adoc#providers,Service Provider Interfaces>> chapter.
|
||||
|
147
server_development/topics/extensions.adoc
Normal file
147
server_development/topics/extensions.adoc
Normal file
|
@ -0,0 +1,147 @@
|
|||
[[_extensions]]
|
||||
|
||||
== Extending the Server
|
||||
|
||||
The {{book.project.name}} SPI framework offers the possibility to implement or override particular built-in providers. However {{book.project.name}}
|
||||
also provides capabilities to extend it's core functionalities and domain. This includes possibilities to:
|
||||
|
||||
* Add custom REST endpoints to the {{book.project.name}} server
|
||||
* Add your own custom SPI
|
||||
* Add custom JPA entities to the {{book.project.name}} data model
|
||||
|
||||
[[_extensions_rest]]
|
||||
=== Add custom REST endpoints
|
||||
|
||||
This is a very powerful extension, which allows you to deploy your own REST endpoints to the {{book.project.name}} server. It enables all kinds of extensions, for example
|
||||
the possibility to trigger functionality on the {{book.project.name}} server, which is not available through the default set of built-in {{book.project.name}} REST endpoints.
|
||||
|
||||
To add a custom REST endpoint, you need to implement the `RealmResourceProviderFactory` and `RealmResourceProvider` interfaces. `RealmResourceProvider` has one important method:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
Object getResource();
|
||||
|
||||
----
|
||||
|
||||
which allows you to return an object, which acts as a https://jax-rs-spec.java.net/[JAX-RS Resource]. For more details, see the Javadoc and our examples.
|
||||
There is a very simple example in the example distribution in `providers/rest` and there is a more advanced example in `providers/domain-extension`,
|
||||
which shows how to add an authenticated REST endpoint and other functionalities like <<extensions.adoc#_extensions_spi,Adding your own SPI>>
|
||||
or <<extensions.adoc#_extensions_jpa,Extending datamodel with your own JPA entities>>.
|
||||
|
||||
For details on how to package and deploy a custom provider, refer to the <<providers.adoc#_providers,Service Provider Interfaces>> chapter.
|
||||
|
||||
[[_extensions_spi]]
|
||||
=== Add your own custom SPI
|
||||
|
||||
This is useful especially with the <<extensions.adoc#_extensions_rest,Custom REST endpoints>>. To add your own kind of SPI, you need to
|
||||
implement the interface `org.keycloak.provider.Spi` and define the ID of your SPI and the `ProviderFactory` and `Provider` classes. That looks like this:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
package org.keycloak.examples.domainextension.spi;
|
||||
|
||||
import ...
|
||||
|
||||
public class ExampleSpi implements Spi {
|
||||
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "example";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Provider> getProviderClass() {
|
||||
return ExampleService.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("rawtypes")
|
||||
public Class<? extends ProviderFactory> getProviderFactoryClass() {
|
||||
return ExampleServiceProviderFactory.class;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
----
|
||||
|
||||
Then you need to create the file `META-INF/services/org.keycloak.provider.Spi` and add the class of your SPI to it. For example:
|
||||
|
||||
[source]
|
||||
----
|
||||
org.keycloak.examples.domainextension.spi.ExampleSpi
|
||||
----
|
||||
|
||||
The next step is to create the interfaces `ExampleServiceProviderFactory`, which extends from `ProviderFactory` and `ExampleService`, which extends from `Provider`.
|
||||
The `ExampleService` will usually contain the business methods you need for your use case. Note that the `ExampleServiceProviderFactory` instance
|
||||
is always scoped per application, however `ExampleService` is scoped per-request (or more accurately per `KeycloakSession` lifecycle).
|
||||
|
||||
Finally you need to implement your providers in the same manner as described in the <<providers.adoc#_providers,Service Provider Interfaces>> chapter.
|
||||
|
||||
For more details, take a look at the example distribution at `providers/domain-extension`, which shows an Example SPI similar to the one above.
|
||||
|
||||
[[_extensions_jpa]]
|
||||
=== Add custom JPA entities to the {{book.project.name}} data model
|
||||
|
||||
If the {{book.project.name}} data model does not exactly match your desired solution, or if you want to add some core functionality to {{book.project.name}},
|
||||
or when you have your own REST endpoint, you might want to extend the {{book.project.name}} data model. We enable you to add your
|
||||
own JPA entities to the {{book.project.name}} JPA `EntityManager` .
|
||||
|
||||
To add your own JPA entities, you need to implement `JpaEntityProviderFactory` and `JpaEntityProvider`. The `JpaEntityProvider`
|
||||
allows you to return a list of your custom JPA entities and provide the location and id of the liquibase changelog. An example implementation can look like this:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
public class ExampleJpaEntityProvider implements JpaEntityProvider {
|
||||
|
||||
// List of your JPA entities.
|
||||
@Override
|
||||
public List<Class<?>> getEntities() {
|
||||
return Collections.<Class<?>>singletonList(Company.class);
|
||||
}
|
||||
|
||||
// This is used to return the location of the Liquibase changelog file.
|
||||
// You can return null if you don't want Liquibase to create and update the DB schema.
|
||||
@Override
|
||||
public String getChangelogLocation() {
|
||||
return "META-INF/example-changelog.xml";
|
||||
}
|
||||
|
||||
// Helper method, which will be used internally by Liquibase.
|
||||
@Override
|
||||
public String getFactoryId() {
|
||||
return "sample";
|
||||
}
|
||||
|
||||
...
|
||||
}
|
||||
----
|
||||
|
||||
In the example above, we added a single JPA entity represented by class `Company`. In the code of your REST endpoint, you can then use something like
|
||||
this to retrieve `EntityManager` and call DB operations on it.
|
||||
|
||||
|
||||
[source,java]
|
||||
----
|
||||
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
||||
Company myCompany = em.find(Company.class, "123");
|
||||
----
|
||||
|
||||
The methods `getChangelogLocation` and `getFactoryId` are important to support automatic updating of your entities by Liquibase. http://www.liquibase.org/[Liquibase]
|
||||
is a framework for updating the database schema, which {{book.project.name}} internally uses to create the DB schema and update the DB schema among versions. You may need to use
|
||||
it as well and create a changelog for your entities. Note that versioning of your own liquibase changelog is independent
|
||||
of {{book.project.name}} versions. In other words, when you update to a new {{book.project.name}} version, you are not forced to update your
|
||||
schema at the same time. And vice versa, you can update your schema even without updating the {{book.project.name}} version. The Liquibase update
|
||||
is always done at the server startup, so to trigger a DB update of your schema, you just need to add the new changeset to your liquibase changelog file (in the example above
|
||||
it's the file `META-INF/example-changelog.xml` (which must be packed in same JAR as the JPA entities and `ExampleJpaEntityProvider`) and then restart server.
|
||||
The DB schema will be automatically updated at startup.
|
||||
|
||||
For more details, take a look at the example distribution at example `providers/domain-extension`, which shows the `ExampleJpaEntityProvider` and `example-changelog.xml` described above.
|
||||
|
||||
NOTE: Don't forget to always backup your database before doing any changes in the Liquibase changelog and triggering a DB update.
|
||||
|
20
server_development/topics/preface.adoc
Normal file
20
server_development/topics/preface.adoc
Normal file
|
@ -0,0 +1,20 @@
|
|||
== Preface
|
||||
|
||||
In some of the example listings, what is meant to be displayed on one line does not fit inside the available page width. These lines have been broken up. A '\' at the end of a line means that a break has been introduced to fit in the page, with the following lines indented.
|
||||
So:
|
||||
|
||||
[source]
|
||||
----
|
||||
Let's pretend to have an extremely \
|
||||
long line that \
|
||||
does not fit
|
||||
This one is short
|
||||
----
|
||||
Is really:
|
||||
|
||||
[source]
|
||||
----
|
||||
Let's pretend to have an extremely long line that does not fit
|
||||
This one is short
|
||||
----
|
||||
|
394
server_development/topics/providers.adoc
Normal file
394
server_development/topics/providers.adoc
Normal file
|
@ -0,0 +1,394 @@
|
|||
[[_providers]]
|
||||
|
||||
== Service Provider Interfaces (SPI)
|
||||
|
||||
Keycloak is designed to cover most use-cases without requiring custom code, but we also want it to be customizable.
|
||||
To achive this Keycloak has a number of Service Provider Interfaces (SPI) which you can implement your own providers for.
|
||||
|
||||
=== Implementing a SPI
|
||||
|
||||
To implement an SPI you need to implement it's ProviderFactory and Provider interfaces. You also need to create a service configuration file.
|
||||
|
||||
For example to implement the Event Listener SPI you need to implement EventListenerProviderFactory and EventListenerProvider and also provide the file
|
||||
`META-INF/services/org.keycloak.events.EventListenerProviderFactory`.
|
||||
|
||||
Example EventListenerProviderFactory:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
package org.acme.provider;
|
||||
|
||||
import ...
|
||||
|
||||
public class MyEventListenerProviderFactory implements EventListenerProviderFactory {
|
||||
|
||||
private List<Event> events;
|
||||
|
||||
public String getId() {
|
||||
return "my-event-listener";
|
||||
}
|
||||
|
||||
public void init(Config.Scope config) {
|
||||
events = new LinkedList();
|
||||
}
|
||||
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
}
|
||||
|
||||
public EventListenerProvider create(KeycloakSession session) {
|
||||
return new MyEventListenerProvider(events);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
NOTE: Keycloak creates a single instance of `EventListenerProviderFactory` which makes it possible to store state for multiple requests.
|
||||
`EventListenerProvider` instances are created by calling create on the factory for each requests so these should be light-weight object.
|
||||
|
||||
Example EventListenerProvider:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
package org.acme.provider;
|
||||
|
||||
import ...
|
||||
|
||||
public class MyEventListenerProvider implements EventListenerProvider {
|
||||
|
||||
private List<Event> events;
|
||||
|
||||
public MyEventListenerProvider(List<Event> events) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEvent(Event event) {
|
||||
events.add(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEvent(AdminEvent event, boolean includeRepresentation) {
|
||||
// Assume this implementation just ignores admin events
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
Example service configuration file (`META-INF/services/org.keycloak.events.EventListenerProviderFactory`):
|
||||
|
||||
[source]
|
||||
----
|
||||
org.acme.provider.MyEventListenerProviderFactory
|
||||
----
|
||||
|
||||
You can configure your provider through `standalone.xml`, `standalone-ha.xml`, or `domain.xml`.
|
||||
See the link:{{book.project.doc_base_url}}{{book.project.doc_info_version_url}}{{book.installguide.link}}[{{book.installguide.name}}] for more details on
|
||||
where the `standalone.xml`, `standalone-ha.xml`, or `domain.xml` file lives.
|
||||
|
||||
For example by adding the following to `standalone.xml`:
|
||||
|
||||
[source,xml]
|
||||
----
|
||||
<spi name="eventsListener">
|
||||
<provider name="my-event-listener" enabled="true">
|
||||
<properties>
|
||||
<property name="aNumber" value="10"/>
|
||||
<property name="aString" value="Foo"/>
|
||||
</properties>
|
||||
</provider>
|
||||
</spi>
|
||||
----
|
||||
|
||||
Then you can retrieve the config in the `ProviderFactory` init method:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
public void init(Config.Scope config) {
|
||||
Integer aNumber = config.getInt("aNumber");
|
||||
String aString = config.get("aString");
|
||||
}
|
||||
----
|
||||
|
||||
Your provider can also lookup other providers if needed. For example:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
public class MyEventListenerProvider implements EventListenerProvider {
|
||||
|
||||
private KeycloakSession session;
|
||||
private List<Event> events;
|
||||
|
||||
public MyEventListenerProvider(KeycloakSession session, List<Event> events) {
|
||||
this.session = session;
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
public void onEvent(Event event) {
|
||||
RealmModel realm = session.realms().getRealm(event.getRealmId());
|
||||
UserModel user = session.users().getUserById(event.getUserId(), realm);
|
||||
|
||||
EmailSenderProvider emailSender = session.getProvider(EmailSenderProvider.class);
|
||||
emailSender.send(realm, user, "Hello", "Hello plain text", "<h1>Hello html</h1>" );
|
||||
}
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
----
|
||||
|
||||
[[_providers_admin_console]]
|
||||
==== Show info from you SPI implementation in Keycloak admin console
|
||||
|
||||
Sometimes it is useful to show additional info about your Provider to a Keycloak administrator. You can show provider build time informations (eg. version of
|
||||
custom provider currently installed), current configuration of the provider (eg. url of remote system your provider talks to) or some operational info
|
||||
(average time of response from remote system your provider talks to). Keycloak admin console provides Server Info page to show this kind of information.
|
||||
|
||||
To show info from your provider it is enough to implement `org.keycloak.provider.ServerInfoAwareProviderFactory` interface in your `ProviderFactory`.
|
||||
|
||||
Example implementation for `MyEventListenerProviderFactory` from previous example:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
package org.acme.provider;
|
||||
|
||||
import ...
|
||||
|
||||
public class MyEventListenerProviderFactory implements EventListenerProviderFactory, ServerInfoAwareProviderFactory {
|
||||
...
|
||||
|
||||
@Override
|
||||
public Map<String, String> getOperationalInfo() {
|
||||
Map<String, String> ret = new LinkedHashMap<>();
|
||||
ret.put("version", "1.0");
|
||||
ret.put("listSizeMax", max + "");
|
||||
ret.put("listSizeCurrent", events.size() + "");
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
=== Registering provider implementations
|
||||
|
||||
There are two ways to register provider implementations. The easiest way is to just throw your provider jar within
|
||||
the Keycloak `deploy/` directory. Keycloak supports hot deployment as well in this scenario. This is also the best
|
||||
solution.
|
||||
|
||||
The alternative is not really recommended, but exists for legacy purposes as the Keycloak deployer didn't exist in
|
||||
previous versions of the project. Keycloak can load provider implementations from JBoss Modules or directly from the file-system.
|
||||
Using Modules is recommended as you can control exactly what classes are available to your provider.
|
||||
Any providers loaded from the file-system uses a classloader with the Keycloak classloader as its parent.
|
||||
|
||||
==== Using the Keycloak Deployer
|
||||
|
||||
If you copy your provider jar to the Keycloak `deploy/` directory, your provider will automatically be deployed.
|
||||
Hot deployment works too. Additionally, your provider jar works similarly to other components deployed in a JBoss/Wildfly
|
||||
environment in that they can use facilities like the `jboss-deployment-structure.xml` file. This file allows you to
|
||||
set up dependencies on other components and load third-party jars and modules.
|
||||
|
||||
Provider jars can also be contained within other deployable units like EARs and WARs. Deploying with a EAR actually makes
|
||||
it really easy to use third party jars as you can just put these libraries in the EAR's `lib/` directory.
|
||||
|
||||
==== Register a provider using Modules
|
||||
|
||||
To register a provider using Modules first create a module.
|
||||
To do this you can either use the jboss-cli script or manually create a folder inside `KEYCLOAK_HOME/modules` and add your jar and a `module.xml`.
|
||||
For example to add the event listener sysout example provider using the `jboss-cli` script execute:
|
||||
|
||||
[source]
|
||||
----
|
||||
KEYCLOAK_HOME/bin/jboss-cli.sh --command="module add --name=org.keycloak.examples.event-sysout --resources=target/event-listener-sysout-example.jar --dependencies=org.keycloak.keycloak-core,org.keycloak.keycloak-server-spi,org.keycloak.keycloak-events-api"
|
||||
----
|
||||
Or to manually create it start by creating the folder `KEYCLOAK_HOME/modules/org/keycloak/examples/event-sysout/main`.
|
||||
Then copy `event-listener-sysout-example.jar` to this folder and create `module.xml` with the following content:
|
||||
|
||||
[source]
|
||||
----
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module xmlns="urn:jboss:module:1.3" name="org.keycloak.examples.event-sysout">
|
||||
<resources>
|
||||
<resource-root path="event-listener-sysout-example.jar"/>
|
||||
</resources>
|
||||
<dependencies>
|
||||
<module name="org.keycloak.keycloak-core"/>
|
||||
<module name="org.keycloak.keycloak-server-spi"/>
|
||||
</dependencies>
|
||||
</module>
|
||||
----
|
||||
|
||||
Once you've created the module you need to register this module with Keycloak.
|
||||
This is done by editing the keycloak-server subsystem section of
|
||||
`standalone.xml`, `standalone-ha.xml`, or `domain.xml`, and adding it to the providers:
|
||||
|
||||
[source,xml]
|
||||
----
|
||||
<subsystem xmlns="urn:jboss:domain:keycloak-server:1.1">
|
||||
<web-context>auth</web-context>
|
||||
<providers>
|
||||
<provider>module:org.keycloak.examples.event-sysout</provider>
|
||||
</providers>
|
||||
...
|
||||
----
|
||||
|
||||
==== Register a provider using file-system
|
||||
|
||||
To register your provider simply copy the JAR including the ProviderFactory and Provider classes and the service configuration file to server's root `providers` directory.
|
||||
|
||||
You can also define multiple provider class-path if you want to create isolated class-loaders.
|
||||
To do this edit `standalone.xml`, `standalone-ha.xml`, or `domain.xml` and add more classpath entries to the providers element.
|
||||
For example:
|
||||
|
||||
[source,xml]
|
||||
----
|
||||
<providers>
|
||||
<provider>classpath:provider1.jar;lib-v1.jar</provider>
|
||||
<provider>classpath:provider2.jar;lib-v2.jar</provider>
|
||||
</providers>
|
||||
----
|
||||
|
||||
The above example will create two separate class-loaders for providers.
|
||||
The classpath entries follow the same syntax as Java classpath, with ';' separating multiple-entries.
|
||||
Wildcard is also supported allowing loading all jars (files with .jar or .JAR extension) in a folder, for example:
|
||||
|
||||
[source,xml]
|
||||
----
|
||||
<providers>
|
||||
<provider>classpath:/home/user/providers/*</provider>
|
||||
</providers>
|
||||
----
|
||||
|
||||
==== Configuring a provider
|
||||
|
||||
You can pass configuration options to your provider by setting them in `standalone.xml`, `standalone-ha.xml`, or `domain.xml`.
|
||||
For example to set the max value for `my-event-listener` add:
|
||||
|
||||
[source.xml]
|
||||
----
|
||||
<spi name="eventsListener">
|
||||
<provider name="my-event-listener" enabled="true">
|
||||
<properties>
|
||||
<property name="max" value="100"/>
|
||||
</properties>
|
||||
</provider>
|
||||
</spi>
|
||||
----
|
||||
|
||||
==== Disabling a provider
|
||||
|
||||
You can disable a provider by setting the enabled attribute for the provider to false
|
||||
in `standalone.xml`, `standalone-ha.xml`, or `domain.xml`.
|
||||
For example to disable the Infinispan user cache provider add:
|
||||
|
||||
[source,xml]
|
||||
----
|
||||
<spi name="userCache">
|
||||
<provider name="infinispan" enabled="false"/>
|
||||
</spi>
|
||||
----
|
||||
|
||||
=== Leveraging Java EE
|
||||
|
||||
The can be packaged within any Java EE component so long as you set up the `META-INF/services`
|
||||
file correctly to point to your providers. For example, if your provider needs to use third party libraries, you
|
||||
can package up your provider within an ear and store these third pary libraries in the ear's `lib/` directory.
|
||||
Also note that provider jars can make use of the `jboss-deployment-structure.xml` file that EJBs, WARS, and EARs
|
||||
can use in a JBoss/Wildfly environment. See the JBoss/Wildfly documentation for more details on this file. It
|
||||
allows you to pull in external dependencies among other fine grain actions.
|
||||
|
||||
`ProviderFactory` implementations are required to be plain java objects. But, we also currently support
|
||||
implementing provider classes as Stateful EJBs. TThis is how you would do it:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Stateful
|
||||
@Local(EjbExampleUserStorageProvider.class)
|
||||
public class EjbExampleUserStorageProvider implements UserStorageProvider,
|
||||
UserLookupProvider,
|
||||
UserRegistrationProvider,
|
||||
UserQueryProvider,
|
||||
CredentialInputUpdater,
|
||||
CredentialInputValidator,
|
||||
OnUserCache
|
||||
{
|
||||
@PersistenceContext
|
||||
protected EntityManager em;
|
||||
|
||||
protected ComponentModel model;
|
||||
protected KeycloakSession session;
|
||||
|
||||
public void setModel(ComponentModel model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
public void setSession(KeycloakSession session) {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
|
||||
@Remove
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
...
|
||||
}
|
||||
----
|
||||
|
||||
You have to define the `@Local` annotation and specify your provider class there. If you don't do this, EJB will
|
||||
not proxy the provider instance correctly and your provider won't work.
|
||||
|
||||
You must put the `@Remove` annotation on the `close()` method of your provider. If you don't, the stateful bean
|
||||
will never be cleaned up and you may eventually see error messages.
|
||||
|
||||
Implementations of `ProviderFactory` are required to be plain java objects. Your factory class would
|
||||
perform a JNDI lookup of the Stateful EJB in its create() method.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
public class EjbExampleUserStorageProviderFactory
|
||||
implements UserStorageProviderFactory<EjbExampleUserStorageProvider> {
|
||||
|
||||
@Override
|
||||
public EjbExampleUserStorageProvider create(KeycloakSession session, ComponentModel model) {
|
||||
try {
|
||||
InitialContext ctx = new InitialContext();
|
||||
EjbExampleUserStorageProvider provider = (EjbExampleUserStorageProvider)ctx.lookup(
|
||||
"java:global/user-storage-jpa-example/" + EjbExampleUserStorageProvider.class.getSimpleName());
|
||||
provider.setModel(model);
|
||||
provider.setSession(session);
|
||||
return provider;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
=== Available SPIs
|
||||
|
||||
Here's a list of the most important available SPIs and a brief description. For more details on each SPI refer to individual sections.
|
||||
If you want to see list of all available SPIs at runtime, you can check `Server Info` page in admin console as described in <<fake/../providers.adoc#_providers_admin_console,Admin Console>> section.
|
||||
|
||||
|
||||
|===
|
||||
|SPI|Description
|
||||
|
||||
|Connections Infinispan|Loads and configures Infinispan connections. The default implementation can load connections from the Infinispan subsystem, or alternatively can be manually configured in standalone.xml
|
||||
|Connections Jpa|Loads and configures Jpa connections. The default implementation can load datasources from WildFly/EAP, or alternatively can be manually configured in standalone.xml
|
||||
|Connections Mongo|Loads and configures MongoDB connections. The default implementation is configured in standalone.xml
|
||||
|Email Sender|Sends email. The default implementation uses JavaMail
|
||||
|Email Template|Format email and uses Email Sender to send the email. The default implementation uses FreeMarker templates
|
||||
|Events Listener|Listen to user related events for example user login success and failures. Keycloak provides two implementations out of box. One that logs events to the server log and another that can send email notifications to users on certain events
|
||||
|Login Protocol|Provides protocols. Keycloak provides implementations of OpenID Connect and SAML 2.0
|
||||
|Realm|Provides realm and application meta-data. Keycloak provides implementations for Relational Databases and MongoDB
|
||||
|Realm Cache|Caches realm and application meta-data to improve performance. Default implementation uses Infinispan
|
||||
|Timer|Executes scheduled tasks. Keycloak provides a basic implementation based on java.util.Timer
|
||||
|User|Provides users and role-mappings. Keycloak provides implementations for Relational Databases and MongoDB
|
||||
|User Cache|Caches users to improve performance. Default implementation uses Infinispan
|
||||
|User Federation|Support syncing users from an external source. Keycloak provides implementations for LDAP and Active Directory
|
||||
|User Sessions|Provides users session information. Keycloak provides implementations for basic in-memory, Infinispan, Relational Databases and MongoDB
|
||||
|===
|
377
server_development/topics/themes.adoc
Normal file
377
server_development/topics/themes.adoc
Normal file
|
@ -0,0 +1,377 @@
|
|||
[[_themes]]
|
||||
== Themes
|
||||
|
||||
{{book.project.name}} provides theme support for web pages and emails. This allows customizing the look and feel of end-user facing pages so they can be
|
||||
integrated with your applications.
|
||||
|
||||
image::../images/login-sunrise.png[caption="",title="Login page with sunrise example theme"]
|
||||
|
||||
=== Theme Types
|
||||
|
||||
A theme can provide one or more types to customize different aspects of {{book.project.name}}. The types available are:
|
||||
|
||||
* Account - Account management
|
||||
* Admin - Admin console
|
||||
* Email - Emails
|
||||
* Login - Login forms
|
||||
* Welcome - Welcome page
|
||||
|
||||
=== Configure Theme
|
||||
|
||||
All theme types, except welcome, are configured through the `Admin Console`. To change the theme used for a realm open the `Admin Console`, select
|
||||
your realm from the drop-down box in the top left corner. Under `Realm Settings` click `Themes`.
|
||||
|
||||
NOTE: To set the theme for the `master` admin console you need to set the admin console theme for the `master` realm. To see the changes to the admin console
|
||||
refresh the page.
|
||||
|
||||
To change the welcome theme you need to edit `standalone.xml`, `standalone-ha.xml`, or `domain.xml`.
|
||||
For more information on where the `standalone.xml`, `standalone-ha.xml`, or `domain.xml` file resides see the link:{{book.project.doc_base_url}}{{book.project.doc_info_version_url}}{{book.installguide.link}}[{{book.installguide.name}}].
|
||||
|
||||
Add `welcomeTheme` to the theme element, for example:
|
||||
|
||||
[source,xml]
|
||||
----
|
||||
<theme>
|
||||
...
|
||||
<welcomeTheme>custom-theme</welcomeTheme>
|
||||
...
|
||||
</theme>
|
||||
----
|
||||
|
||||
If the server is running you need to restart the server for the changes to the welcome theme to take effect.
|
||||
|
||||
=== Default Themes
|
||||
|
||||
{{book.project.name}} comes bundled with default themes in the server's root `themes` directory. To simplify upgrading you should not edit the bundled themes
|
||||
directly. Instead create your own theme that extends one of the bundle themes.
|
||||
|
||||
=== Creating a Theme
|
||||
|
||||
A theme consists of:
|
||||
|
||||
* HTML templates (http://freemarker.org[Freemarker Templates])
|
||||
* Images
|
||||
* Message bundles
|
||||
* Stylesheets
|
||||
* Scripts
|
||||
* Theme properties
|
||||
|
||||
Unless you plan to replace every single page you should extend another theme. Most likely you will want to extend the {{book.project.name}} theme, but you could also
|
||||
consider extending the base theme if you are significantly changing the look and feel of the pages. The base theme primarily consists of HTML templates and
|
||||
message bundles, while the {{book.project.name}} theme primarily contains images and stylesheets.
|
||||
|
||||
When extending a theme you can override individual resources (templates, stylesheets, etc.). If you decide to override HTML templates bear in mind that you may
|
||||
need to update your custom template when upgrading to a new release.
|
||||
|
||||
While creating a theme it's a good idea to disable caching as this makes it possible to edit theme resources directly from the `themes` directory without
|
||||
restarting {{book.project.name}}. To do this edit `standalone.xml`. For `theme` set `staticMaxAge` to `-1` and both
|
||||
`cacheTemplates` and `cacheThemes` to `false`:
|
||||
|
||||
[source,xml]
|
||||
----
|
||||
<theme>
|
||||
<staticMaxAge>-1</staticMaxAge>
|
||||
<cacheThemes>false</cacheThemes>
|
||||
<cacheTemplates>false</cacheTemplates>
|
||||
...
|
||||
</theme>
|
||||
----
|
||||
|
||||
Remember to re-enable caching in production as it will significantly impact performance.
|
||||
|
||||
To create a new theme start by creating a new directory in the `themes` directory. The name of the directory becomes the name of the theme. For example to
|
||||
create a theme called `mytheme` create the directory `themes/mytheme`.
|
||||
|
||||
Inside the theme directory create a directory for each of the types your theme is going to provide. For example to add the login type to the `mytheme`
|
||||
theme create the directory `themes/mytheme/login`.
|
||||
|
||||
For each type create a file `theme.properties` which allows setting some configuration for the theme. For example to configure the theme `themes/mytheme/login`
|
||||
that we just created to extend the base theme and import some common resources create the file `themes/mytheme/login/theme.properties` with following contents:
|
||||
|
||||
[source]
|
||||
----
|
||||
parent=base
|
||||
import=common/keycloak
|
||||
----
|
||||
|
||||
You have now created a theme with support for the login type. To check that it works open the admin console. Select your realm and click on `Themes`.
|
||||
For `Login Theme` select `mytheme` and click `Save`. Then open the login page for the realm.
|
||||
|
||||
You can do this either by login through your application or by opening the Account Management console (`/realms/{realm name}/account`).
|
||||
|
||||
To see the effect of changing the parent theme, set `parent=keycloak` in `theme.properties` and refresh the login page.
|
||||
|
||||
==== Theme Properties
|
||||
|
||||
Theme properties are set in the file `<THEME TYPE>/theme.properties` in the theme directory.
|
||||
|
||||
* parent - Parent theme to extend
|
||||
* import - Import resources from another theme
|
||||
* styles - Space-separated list of styles to include
|
||||
* locales - Comma-separated list of supported locales
|
||||
|
||||
There are a list of properties that can be used to change the css class used for certain element types. For a list of these properties look at the theme.properties
|
||||
file in the corresponding type of the keycloak theme (`themes/keycloak/<THEME TYPE>/theme.properties`).
|
||||
|
||||
You can also add your own custom properties and use them from custom templates.
|
||||
|
||||
==== Stylesheets
|
||||
|
||||
A theme can have one or more stylesheets, to add a stylesheet create a file in the `<THEME TYPE>/resources/css` directory of your theme. Then add it to the `styles`
|
||||
property in `theme.properties`.
|
||||
|
||||
For example to add `styles.css` to the `mytheme` create `themes/mytheme/login/resources/css/styles.css` with the following content:
|
||||
|
||||
[source,css]
|
||||
----
|
||||
.login-pf body {
|
||||
background: DimGrey none;
|
||||
}
|
||||
----
|
||||
|
||||
Then edit `themes/mytheme/login/theme.properties` and add:
|
||||
|
||||
[source]
|
||||
----
|
||||
styles=css/styles.css
|
||||
----
|
||||
|
||||
To see the changes open the login page for your realm. You will notice that the only styles being applied are those from your custom stylesheet. To include the
|
||||
styles from the parent theme you need to load the styles from that theme as well. Do this by editing `themes/mytheme/login/theme.properties` and changing `styles`
|
||||
to:
|
||||
|
||||
[source]
|
||||
----
|
||||
styles=lib/patternfly/css/patternfly.css lib/zocial/zocial.css css/login.css css/styles.css
|
||||
----
|
||||
|
||||
NOTE: To override styles from the parent stylesheets it's important that your stylesheet is listed last.
|
||||
|
||||
==== Scripts
|
||||
|
||||
A theme can have one or more scripts, to add a script create a file in the `<THEME TYPE>/resources/js` directory of your theme. Then add it to the `scripts`
|
||||
property in `theme.properties`.
|
||||
|
||||
For example to add `script.js` to the `mytheme` create `themes/mytheme/login/resources/js/script.js` with the following content:
|
||||
|
||||
[source,javascript]
|
||||
----
|
||||
alert('Hello');
|
||||
----
|
||||
|
||||
Then edit `themes/mytheme/login/theme.properties` and add:
|
||||
|
||||
[source]
|
||||
----
|
||||
scripts=js/script.js
|
||||
----
|
||||
|
||||
==== Images
|
||||
|
||||
To make images available to the theme add them to the `<THEME TYPE>/resources/img` directory of your theme. These can be used from within stylesheets or
|
||||
directly in HTML templates.
|
||||
|
||||
For example to add an image to the `mytheme` copy an image to `themes/mytheme/login/resources/img/image.jpg`.
|
||||
|
||||
You can then use this image from within a custom stylesheet with:
|
||||
|
||||
[source,css]
|
||||
----
|
||||
body {
|
||||
background-image: url('../img/image.jpg');
|
||||
background-size: cover;
|
||||
}
|
||||
----
|
||||
|
||||
Or to use directly in HTML templates add the following to a custom HTML template:
|
||||
|
||||
[source,html]
|
||||
----
|
||||
<img src="${url.resourcesPath}/img/image.jpg">
|
||||
----
|
||||
|
||||
==== Messages
|
||||
|
||||
Text in the templates are loaded from message bundles. A theme that extends another theme will inherit all messages from the parents message bundle and you can
|
||||
override individual messages by adding `<THEME TYPE>/messages/messages_en.properties` to your theme.
|
||||
|
||||
For example to replace `Username` on the login form with `Your Username` for the `mytheme` create the file
|
||||
`themes/mytheme/login/messages/messages_en.properties` with the following content:
|
||||
|
||||
[source]
|
||||
----
|
||||
usernameOrEmail=Your Username
|
||||
----
|
||||
|
||||
Within a message values like `{0}` and `{1}` are replaced with arguments when the message is used. For example {0} in `Log in to {0}` is replaced with the name
|
||||
of the realm.
|
||||
|
||||
==== Internationalization
|
||||
|
||||
{{book.project.name}} supports internationalization. To enable internationalization for a realm see {{book.project.doc_base_url}}{{book.project.doc_info_version_url}}{{book.adminguide.link}}[{{book.adminguide.name}}]. This
|
||||
section describes how you can add your own language.
|
||||
|
||||
To add a new language create the file `<THEME TYPE>/messages/messages_<LOCALE>` in the directory of your theme. Then add it to the `locales` property in
|
||||
`<THEME TYPE>/theme.properties`. For a language to be available to users the realms `login`, `account` and `email` theme has to support the language, so you
|
||||
need to add your language for those theme types.
|
||||
|
||||
For example, to add Norwegian translations to the `mytheme` theme create the file `themes/mytheme/login/messages/messages_no.properties` with the
|
||||
following content:
|
||||
|
||||
[source]
|
||||
----
|
||||
usernameOrEmail=Brukernavn
|
||||
password=Passord
|
||||
----
|
||||
|
||||
All messages you don't provide a translation for will use the default English translation.
|
||||
|
||||
Then edit `themes/mytheme/login/theme.properties` and add:
|
||||
|
||||
[source]
|
||||
----
|
||||
locales=en,no
|
||||
----
|
||||
|
||||
You also need to do the same for the `account` and `email` theme types. To do this create `themes/mytheme/account/messages/messages_no.properties` and
|
||||
`themes/mytheme/email/messages/messages_no.properties`. Leaving these files empty will result in the English messages being used. Then copy
|
||||
`themes/mytheme/login/theme.properties` to `themes/mytheme/account/theme.properties` and `themes/mytheme/email/theme.properties`.
|
||||
|
||||
Finally you need to add a translation for the language selector. This is done by adding a message to the English translation. To do this add the following to
|
||||
`themes/mytheme/account/messages/messages_en.properties` and `themes/mytheme/login/messages/messages_en.properties`:
|
||||
|
||||
[source]
|
||||
----
|
||||
locale_no=Norsk
|
||||
----
|
||||
|
||||
By default message properties files should be encoded using ISO-8859-1. It's also possible to specify the encoding using a special header. For example to use UTF-8 encoding:
|
||||
|
||||
[source]
|
||||
----
|
||||
# encoding: UTF-8
|
||||
usernameOrEmail=....
|
||||
----
|
||||
|
||||
==== HTML Templates
|
||||
|
||||
{{book.project.name}} uses http://freemarker.org[Freemarker Templates] in order to generate HTML. You can override individual templates in your own theme by
|
||||
creating `<THEME TYPE>/<TEMPLATE>.ftl`. For a list of templates used see `themes/base/<THEME TYPE>`.
|
||||
|
||||
When creating a custom template it is a good idea to copy the template from the base theme to your own theme, then applying the modifications you need. Bear in
|
||||
mind when upgrading to a new version of {{book.project.name}} you may need to update your custom templates to apply changes to the original template if
|
||||
applicable.
|
||||
|
||||
For example to create a custom login form for the `mytheme` theme copy `themes/base/login/login.ftl` to `themes/mytheme/login` and open it in an editor.
|
||||
After the first line (<#import ...>) add `<h1>HELLO WORLD!</h1>` like so:
|
||||
|
||||
[source,html]
|
||||
----
|
||||
<#import "template.ftl" as layout>
|
||||
<h1>HELLO WORLD!</h1>
|
||||
...
|
||||
----
|
||||
|
||||
Check out the http://freemarker.org/docs/index.html[FreeMarker Manual] for more details on how to edit templates.
|
||||
|
||||
==== Emails
|
||||
|
||||
To edit the subject and contents for emails, for example password recovery email, add a message bundle to the `email` type of your theme. There's three messages for each email. One for the subject, one for the plain text body and one for the html body.
|
||||
|
||||
To see all emails available take a look at `themes/base/email/messages/messages_en.properties`.
|
||||
|
||||
For example to change the password recovery email for the `mytheme` theme create `themes/mytheme/email/messages/messages_en.properties` with the following
|
||||
content:
|
||||
[source]
|
||||
----
|
||||
passwordResetSubject=My password recovery
|
||||
passwordResetBody=Reset password link: {0}
|
||||
passwordResetBodyHtml=<a href="{0}">Reset password</a>
|
||||
----
|
||||
|
||||
=== Deploying Themes
|
||||
|
||||
Themes can be deployed to {{book.project.name}} by copying the theme directory to `themes` or it can be deployed as an archive. During development copying the
|
||||
theme to the `themes` directory, but in production you may want to consider using an `archive`. An `archive` makes it simpler to have a versioned copy of
|
||||
the theme, especially when you have multiple instances of {{book.project.name}} for example with clustering.
|
||||
|
||||
To deploy a theme as an archive you need to create a ZIP archive with the theme resources. You also need to add a file `META-INF/keycloak-themes.json` to the
|
||||
archive that lists the available themes in the archive as well as what types each theme provides.
|
||||
|
||||
For example for the `mytheme` theme create `mytheme.zip` with the contents:
|
||||
|
||||
* META-INF/keycloak-themes.json
|
||||
* theme/mytheme/login/theme.properties
|
||||
* theme/mytheme/login/login.ftl
|
||||
* theme/mytheme/login/resources/css/styles.css
|
||||
* theme/mytheme/login/resources/img/image.png
|
||||
* theme/mytheme/login/messages/messages_en.properties
|
||||
* theme/mytheme/email/messages/messages_en.properties
|
||||
|
||||
The contents of `META-INF/keycloak-themes.json` in this case would be:
|
||||
|
||||
[source]
|
||||
----
|
||||
{
|
||||
"themes": [{
|
||||
"name" : "mytheme",
|
||||
"types": [ "login", "email" ]
|
||||
}]
|
||||
}
|
||||
----
|
||||
|
||||
A single archive can contain multiple themes and each theme can support one or more types.
|
||||
|
||||
The deploy the archive to {{book.project.name}} you can either manually create a module in `modules` or use the `jboss-cli` command. It's simplest to use
|
||||
`jboss-cli` as it creates the required directories and module descriptor for you.
|
||||
|
||||
To deploy `mytheme.zip` on Linux run:
|
||||
|
||||
[source,bash]
|
||||
----
|
||||
bin/jboss-cli.sh --command="module add --name=org.example.mytheme --resources=mytheme.zip"
|
||||
----
|
||||
|
||||
On Windows run:
|
||||
|
||||
[source]
|
||||
----
|
||||
bin\jboss-cli.bat --command="module add --name=org.example.mytheme --resources=mytheme.zip"
|
||||
----
|
||||
|
||||
This command creates `modules/org/example/mytheme/main` directory with the `mytheme.zip` archive and `module.xml`.
|
||||
|
||||
To manually create the module create the directory `modules/org/keycloak/example/mytheme/main`, copy `mytheme.zip` to this directory and create the file
|
||||
`modules/org/keycloak/example/mytheme/main/module.xml` with the contents:
|
||||
|
||||
[source,xml]
|
||||
----
|
||||
<?xml version="1.0" ?>
|
||||
<module xmlns="urn:jboss:module:1.3" name="org.keycloak.example.themes">
|
||||
<resources>
|
||||
<resource-root path="mytheme.zip"/>
|
||||
</resources>
|
||||
</module>
|
||||
----
|
||||
|
||||
You also need to register the module with {{book.project.name}}. This is done by editing `standalone.xml`, `standalone-ha.xml`, or `domain.xml`.
|
||||
For more information on where the `standalone.xml`, `standalone-ha.xml`, or `domain.xml` file resides see the link:{{book.project.doc_base_url}}{{book.project.doc_info_version_url}}{{book.installguide.link}}[{{book.installguide.name}}].
|
||||
|
||||
Then find the `theme` tag under `keycloak-server` subsystem and add the module to `theme/modules/module`. For example:
|
||||
|
||||
[source,xml]
|
||||
----
|
||||
<theme>
|
||||
...
|
||||
<modules>
|
||||
<module>org.example.mytheme</module>
|
||||
</modules>
|
||||
</theme>
|
||||
----
|
||||
|
||||
If the server is running you need to restart the server after changing `standalone.xml`, `standalone-ha.xml`, or `domain.xml`.
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
If the same theme is deployed to both the `themes` directory and as a module the version in the `themes` directory is used.
|
||||
====
|
15
server_development/topics/user-federation-mapper.adoc
Executable file
15
server_development/topics/user-federation-mapper.adoc
Executable file
|
@ -0,0 +1,15 @@
|
|||
[[_user_federation_mapper]]
|
||||
== User Federation Mapper SPI
|
||||
|
||||
=== LDAP Mapper
|
||||
|
||||
For the more advanced usecases, you have the possibility to create your own implementation of LDAP mapper or just subclass from some already existing mapper
|
||||
implementation. You will need to implement `UserFederationMapperFactory` interface.
|
||||
|
||||
In most cases, instead of creating `UserFederationMapperFactory` from scratch, you can create subclasses of `AbstractLDAPFederationMapperFactory`, which itself
|
||||
implements `UserFederationMapperFactory`.
|
||||
|
||||
Then you need to create mapper implementation, which will be subclass of `AbstractLDAPFederationMapper` (this mapper implementation will be returned by
|
||||
`YourAbstractLDAPFederationMapperFactorySubclass.createMapper` method).
|
||||
|
||||
For details on how to package and deploy a custom provider refer to the <<providers.adoc#providers,Service Provider Interfaces>> chapter.
|
15
server_development/topics/user-federation.adoc
Executable file
15
server_development/topics/user-federation.adoc
Executable file
|
@ -0,0 +1,15 @@
|
|||
[[_user_federation]]
|
||||
== User Federation SPI
|
||||
|
||||
The keycloak examples directory contains an example of a simple User Federation Provider backed by a simple properties file. See `providers/federation-provider`
|
||||
in the examples distribution. Most of how to create a federation provider is explained directly within the example code, but some information is here too.
|
||||
|
||||
Writing a User Federation Provider starts by implementing the `UserFederationProvider` and `UserFederationProviderFactory` interfaces. Please see the Javadoc
|
||||
and example for complete details on how to do this.
|
||||
|
||||
Some important methods of note: getUserByUsername() and getUserByEmail() require that you query your federated storage and if the user exists create and import
|
||||
the user into Keycloak storage. How much metadata you import is fully up to you. This import is done by invoking methods on the object returned
|
||||
`KeycloakSession.userStorage()` to add and import user information. The proxy() method will be called whenever Keycloak has found an imported UserModel.
|
||||
This allows the federation provider to proxy the UserModel which is useful if you want to support external storage updates on demand.
|
||||
|
||||
For details on how to package and deploy a custom provider refer to the <<providers.adoc#providers,Service Provider Interfaces>> chapter.
|
42
server_development/topics/user-storage.adoc
Normal file
42
server_development/topics/user-storage.adoc
Normal file
|
@ -0,0 +1,42 @@
|
|||
[[_user-storage-spi]]
|
||||
== User Storage SPI
|
||||
|
||||
The User Storage SPI allows you to write extensions to {{book.project.name}} to connect to external user databases and credential
|
||||
stores. The built-in LDAP and ActiveDirectory support is an implementation of this SPI in action. Out of the box,
|
||||
{{book.project.name}} uses its local database to create, update, and lookup users and validation credentials from. Often though,
|
||||
organizations have existing external proprietary user databases that they cannot migrate to {{book.project.name}}'s data model.
|
||||
For those situations, Application developers can write implementations of the User Storage SPI to bridge between the external user store and the internal
|
||||
user object model that {{book.project.name}} uses to login users and manage them.
|
||||
|
||||
When the {{book.project.name}} runtime needs to look up a user, like when a user is logging in, it performs a number of
|
||||
steps to locate the user. It first looks to see if the user is in the user cache, if its there it uses that in-memory
|
||||
representation. Then it looks for the user within {{book.project.name}} local database. If its not there, it then
|
||||
loops through User Storage SPI provider implementations to perform the user query until one of them returns
|
||||
the user the runtime is looking for. The provider queries the external user store for the user and maps the external data representation
|
||||
of the user to {{book.project.name}}'s user metamodel.
|
||||
|
||||
User Storage SPI provider implementations can also perform complex criteria queries, perform CRUD operations on users,
|
||||
validate and manage credentials, or perform bulk updates of many users at once. It all depends on the capabilities of
|
||||
the external store.
|
||||
|
||||
User Storage SPI provider implementations are packaged and deployed similarly (and often are) to Java EE components.
|
||||
The are not enabled by default, but instead must be enabled and configured per realm under the `User Federation` tab
|
||||
in the administration console.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
69
server_development/topics/user-storage/augmenting.adoc
Normal file
69
server_development/topics/user-storage/augmenting.adoc
Normal file
|
@ -0,0 +1,69 @@
|
|||
|
||||
=== Augmenting External Storage
|
||||
|
||||
The `PropertyProfileUserStorageProvider` example is really limited. While we will be able to login with users stored
|
||||
in a property file, we won't be able to do much else. If users loaded by this provider need special role or group
|
||||
mappings to fully access particular applications there is no way for us to add additional role mappings to these users.
|
||||
You also can't modify or add additional important attributes like email, first and last name.
|
||||
|
||||
For these types of situations, {{book.project.name}} allows you to augment your external store by storing extra information
|
||||
in {{book.project.name}}'s database. This is called federated user storage and is encapsulated within the
|
||||
`org.keycloak.storage.federated.UserFederatedStorageProvider` class.
|
||||
|
||||
.UserFederatedStorageProvider
|
||||
[source,java]
|
||||
----
|
||||
package org.keycloak.storage.federated;
|
||||
|
||||
public interface UserFederatedStorageProvider extends Provider {
|
||||
|
||||
Set<GroupModel> getGroups(RealmModel realm, String userId);
|
||||
void joinGroup(RealmModel realm, String userId, GroupModel group);
|
||||
void leaveGroup(RealmModel realm, String userId, GroupModel group);
|
||||
List<String> getMembership(RealmModel realm, GroupModel group, int firstResult, int max);
|
||||
|
||||
...
|
||||
|
||||
----
|
||||
|
||||
The `UserFederatedStorageProvider` instance is available on the `KeycloakSession.userFederatedStorage()` method.
|
||||
It has all different kinds of methods for storing attributes, group and role mappings, different credential types,
|
||||
and required actions. If your external store's datamodel cannot support the full {{book.project.name}} feature
|
||||
set, then this service can fill in the gaps.
|
||||
|
||||
{{book.project.name}} comes with a helper class `org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage`
|
||||
that will delegate every single `UserModel` method except get/set of username to user federated storage. Override
|
||||
the methods you need to override to delegate to your external storage representations. It is strongly
|
||||
suggested you read the javadoc of this class as it has smaller protected methods you may want to override. Specifically
|
||||
surrounding group membership and role mappings.
|
||||
|
||||
==== Augmentation Example
|
||||
|
||||
In our `PropertyFileUserStorageProvider` example, we just need a simple change to our provider to use the
|
||||
`AbstractUserAdapterFederatedStorage`.
|
||||
|
||||
.PropertyFileUserStorageProvider
|
||||
[source,java]
|
||||
----
|
||||
protected UserModel createAdapter(RealmModel realm, String username) {
|
||||
return new AbstractUserAdapterFederatedStorage(session, realm, model) {
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setUsername(String username) {
|
||||
String pw = (String)properties.remove(username);
|
||||
if (pw != null) {
|
||||
properties.put(username, pw);
|
||||
save();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
----
|
||||
|
||||
We instead define an anonymous class implementation of `AbstractUserAdapterFederatedStorage`. The `setUsername()`
|
||||
method makes changes to the properties file and saves it.
|
||||
|
107
server_development/topics/user-storage/cache.adoc
Normal file
107
server_development/topics/user-storage/cache.adoc
Normal file
|
@ -0,0 +1,107 @@
|
|||
|
||||
=== User Caches
|
||||
|
||||
When a user is loaded by id, username, or email queries it will be cached. When a user is cached, it iterates through
|
||||
the entire `UserModel` interface and pulls this information to a local in-memory only cache. In a cluster, this cache
|
||||
is still local, but it becomes an invalidation cache. When a user is modified, it is evicted. This eviction event
|
||||
is propagated to the entire cluster so that other nodes' user cache is also invalidated.
|
||||
|
||||
==== Managing the user cache
|
||||
|
||||
You can get access to the user cache by calling `KeycloakSession.userCache()`.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
/**
|
||||
* All these methods effect an entire cluster of Keycloak instances.
|
||||
*
|
||||
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
|
||||
* @version $Revision: 1 $
|
||||
*/
|
||||
public interface UserCache extends UserProvider {
|
||||
/**
|
||||
* Evict user from cache.
|
||||
*
|
||||
* @param user
|
||||
*/
|
||||
void evict(RealmModel realm, UserModel user);
|
||||
|
||||
/**
|
||||
* Evict users of a specific realm
|
||||
*
|
||||
* @param realm
|
||||
*/
|
||||
void evict(RealmModel realm);
|
||||
|
||||
/**
|
||||
* Clear cache entirely.
|
||||
*
|
||||
*/
|
||||
void clear();
|
||||
}
|
||||
----
|
||||
|
||||
There are methods for evicting a specific users, users contained in a specific realm, or the entire cache.
|
||||
|
||||
==== OnUserCache Callback Interface
|
||||
|
||||
You may want to cache additional information that is specific to your provider implementation. The User Storage SPI
|
||||
has a callback whenever a user is cached: `org.keycloak.models.cache.OnUserCache`.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
public interface OnUserCache {
|
||||
void onCache(RealmModel realm, CachedUserModel user, UserModel delegate);
|
||||
}
|
||||
----
|
||||
|
||||
Your provider class should implement this interface if it wants this callback. The `UserModel` delegate parameter
|
||||
is the `UserModel` instance returned by your provider. The `CachedUserModel` is an expanded `UserModel` interface.
|
||||
This is the instance that is cached locally in local storage.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
public interface CachedUserModel extends UserModel {
|
||||
|
||||
/**
|
||||
* Invalidates the cache for this user and returns a delegate that represents the actual data provider
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
UserModel getDelegateForUpdate();
|
||||
|
||||
boolean isMarkedForEviction();
|
||||
|
||||
/**
|
||||
* Invalidate the cache for this model
|
||||
*
|
||||
*/
|
||||
void invalidate();
|
||||
|
||||
/**
|
||||
* When was the model was loaded from database.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
long getCacheTimestamp();
|
||||
|
||||
/**
|
||||
* Returns a map that contains custom things that are cached along with this model. You can write to this map.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
ConcurrentHashMap getCachedWith();
|
||||
}
|
||||
----
|
||||
|
||||
This `CachedUserModel` interface allows you to evict the user from cache and get the provider `UserModel` instance.
|
||||
The most interesting method is `getCachedWith()`. This returns a map that allows you to cache additional information
|
||||
pertaining to the user. For example, credentials are not part of the `UserModel` interface. If you wanted to cache
|
||||
credentials in memory, you would implement `OnUserCache` and cache your user's credentials using the `getCachedWith()`
|
||||
method.
|
||||
|
||||
==== Cache Policies
|
||||
|
||||
Each configured user storage provider can specify unique cache policies. Go to the admin console management page
|
||||
for your provider to see how to do this.
|
||||
|
131
server_development/topics/user-storage/configuration.adoc
Normal file
131
server_development/topics/user-storage/configuration.adoc
Normal file
|
@ -0,0 +1,131 @@
|
|||
|
||||
=== Configuration Techniques
|
||||
|
||||
Our `PropertyFileUserStorageProvider` example is bit contrived. It is hardcoded to a property file that is embedded
|
||||
in the jar of the provider. Not very useful at all. We may want to make the location of this file configurable per
|
||||
instance of the provider. In other words, we may want to re-use this provider mulitple times in multiple different realms
|
||||
and point to completely different user property files. We'll also want to do this configuration within the admin
|
||||
console UI.
|
||||
|
||||
The `UserStorageProviderFactory` has additional methods you can implement that deal with provider configuration.
|
||||
You describe the variables you want to configure per provider and the admin console will automatically render
|
||||
a generic input page to gather this configuration. There's also callback methods to validate configuration
|
||||
before it is saved, when a provider is created for the first time, and when it is updated. `UserStorageProviderFactory`
|
||||
inherits these methods from the `org.keycloak.component.ComponentFactory` interface.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
List<ProviderConfigProperty> getConfigProperties();
|
||||
|
||||
default
|
||||
void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model)
|
||||
throws ComponentValidationException
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
default
|
||||
void onCreate(KeycloakSession session, RealmModel realm, ComponentModel model) {
|
||||
|
||||
}
|
||||
|
||||
default
|
||||
void onUpdate(KeycloakSession session, RealmModel realm, ComponentModel model) {
|
||||
|
||||
}
|
||||
----
|
||||
|
||||
The `ComponentFactory.getConfigProperties()` method returns a list of `org.keycloak.provider.ProviderConfigProperty`
|
||||
instances. These instances declare metadata that is needed to render and store each configuration variable of the
|
||||
provider.
|
||||
|
||||
==== Configuration Example
|
||||
|
||||
Let's expand our `PropertyFileUserStorageProviderFactory` example to allow you to to point a provider instance to a specific
|
||||
file on disk.
|
||||
|
||||
.PropertyFileUserStorageProviderFactory
|
||||
[source,java]
|
||||
----
|
||||
public class PropertyFileUserStorageProviderFactory
|
||||
implements UserStorageProviderFactory<PropertyFileUserStorageProvider> {
|
||||
|
||||
protected static final List<ProviderConfigProperty> configMetadata;
|
||||
|
||||
static {
|
||||
configMetadata = ProviderConfigurationBuilder.create()
|
||||
.property().name("path")
|
||||
.type(ProviderConfigProperty.STRING_TYPE)
|
||||
.label("Path")
|
||||
.defaultValue("${jboss.server.config.dir}/example-users.properties")
|
||||
.helpText("File path to properties file")
|
||||
.default
|
||||
.add().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderConfigProperty> getConfigProperties() {
|
||||
return configMetadata;
|
||||
}
|
||||
----
|
||||
|
||||
The `ProviderConfigurationBuilder` class is a great helper class to create a list of configuration properties. Here
|
||||
we specify a variable named `path` that is a string type. In the admin console config page for this provider,
|
||||
this config variable will be labed as `Path` and have a default value of `${jboss.server.config.dir}/example-users.properties`.
|
||||
When you hover over the tooltip of this config option, it will display the help text `File path to properties file`.
|
||||
|
||||
The next thing we want to do is to verify that this file exists on disk. We don't want to enable an instance of this
|
||||
provider in the realm unless it points to a valid user property file. To do this we implement the `validateConfiguration()`
|
||||
method.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Override
|
||||
public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config)
|
||||
throws ComponentValidationException {
|
||||
String fp = config.getConfig().getFirst("path");
|
||||
if (fp == null) throw new ComponentValidationException("user property file does not exist");
|
||||
fp = EnvUtil.replace(fp);
|
||||
File file = new File(fp);
|
||||
if (!file.exists()) {
|
||||
throw new ComponentValidationException("user property file does not exist");
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
In the `validateConfiguration()` method we get the config variable from the `ComponentModel` and we check to see
|
||||
if that file exists on disk. Notice that we use the `org.keycloak.common.util.EnvUtil.replace()`method. With this method
|
||||
any string that has `${}` within it will replace that with a system property value. The `${jboss.server.config.dir}`
|
||||
string corresponds to the `configuration/` directory of our server and is really useful for this example.
|
||||
|
||||
Next thing we have to do is remove the old `init()` method. We do this because user property files are going to be
|
||||
unique per provider instance. We move this logic to the `create()` method.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Override
|
||||
public PropertyFileUserStorageProvider create(KeycloakSession session, ComponentModel model) {
|
||||
String path = model.getConfig().getFirst("path");
|
||||
|
||||
Properties props = new Properties();
|
||||
try {
|
||||
InputStream is = new FileInputStream(path);
|
||||
props.load(is);
|
||||
is.close();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
return new PropertyFileUserStorageProvider(session, model, props);
|
||||
}
|
||||
----
|
||||
|
||||
This logic, is of course, is really inefficient as every different transaction will read the entire user property file from disk,
|
||||
but hopefully this illustrates, in a simple way, how to hook in configuration variables.
|
||||
|
||||
==== Configure in Admin Console
|
||||
|
||||
Now that the configuration is enabled, you can set the `path` variable when you configure the provider in the admin console.
|
||||
|
||||
.Configured Provider
|
||||
image:../../{{book.images}}/storage-provider-with-config.png[]
|
124
server_development/topics/user-storage/import.adoc
Normal file
124
server_development/topics/user-storage/import.adoc
Normal file
|
@ -0,0 +1,124 @@
|
|||
|
||||
=== Import Implementation Strategy
|
||||
|
||||
When implementing a user storage provider, there's another strategy you can take. Instead of using user federated storage,
|
||||
you can create a user locally in the {{book.project.name}} built in user database and copy attributes from your external
|
||||
store into this local copy. There are a bunch of advantages to this approach.
|
||||
|
||||
* {{book.project.name}} basically becomes a persistence user cache for your external store. Once the user is imported
|
||||
you'll no longer hit the external store thus taking load off of it.
|
||||
* If you are moving to {{book.project.name}} as your official user store and deprecating the old external store, you
|
||||
can slowly migrate applications to use {{book.project.name}}. When all applications have been migrated, unlink the
|
||||
imported user, and retire the old legacy external store.
|
||||
|
||||
There are some obvious disadvantages though to using an import strategy:
|
||||
|
||||
* Looking up a user for the first time will require multiple updates to {{book.project.name}} database. This can
|
||||
be a big performance loss under load and put a lot of strain on the {{book.project.name}} database. The user federated
|
||||
storage approach will only store extra data as needed and may never be used depending on the capabilities of your external store.
|
||||
* With the import approach, you have to keep local keycloak storage and external storage in sync. The User Storage SPI
|
||||
has capability interfaces that you can implement to support synchronization, but this can quickly become painful and messy.
|
||||
|
||||
To implement the import strategy you simply check to see first if the user has been imported locally. If so return the
|
||||
local user, if not create the user locally and import data from the external store. You can also proxy the local user
|
||||
so that most changes are automatically synchronized.
|
||||
|
||||
This will be a bit contrived, but we can extend our `PropertyFileUserStorageProvider` to take this approach. We
|
||||
begin first by modifying the `createAdapter()` method.
|
||||
|
||||
.PropertyFileUserStorageProvider
|
||||
[source.java]
|
||||
----
|
||||
protected UserModel createAdapter(RealmModel realm, String username) {
|
||||
UserModel local = session.userLocalStorage().getUserByUsername(username, realm);
|
||||
if (local == null) {
|
||||
local = session.userLocalStorage().addUser(realm, username);
|
||||
local.setFederationLink(model.getId());
|
||||
}
|
||||
return new UserModelDelegate(local) {
|
||||
@Override
|
||||
public void setUsername(String username) {
|
||||
String pw = (String)properties.remove(username);
|
||||
if (pw != null) {
|
||||
properties.put(username, pw);
|
||||
save();
|
||||
}
|
||||
super.setUsername(username);
|
||||
}
|
||||
};
|
||||
}
|
||||
----
|
||||
|
||||
In this method we call the `KeycloakSession.userLocalStorage()` method to obtain a reference to local {{book.project.name}}
|
||||
user storage. We see if the user is stored locally, if not, we add it locally. Also note that we call
|
||||
`UserModel.setFederationLink()` and pass in the id of the `ComponentModel` of our provider. This sets a link between
|
||||
the provider and the imported user.
|
||||
|
||||
NOTE: When a user storage provider is removed, any user imported by it will also be removed. This is one of the
|
||||
purposes of calling `UserModel.setFederationLink()`.
|
||||
|
||||
Another thing to note is that if a local user is linked, your storage provider will still be delegated to for methods
|
||||
that it implements from the `CredentialInputValidator` and `CredentialInputUpdater` interfaces. Returning `false`
|
||||
from a validation or update will just result in {{book.project.name}} seeing if it can validate or update using
|
||||
local storage.
|
||||
|
||||
Also notice that we are proxying the local user using the `org.keycloak.models.utils.UserModelDelegate' class.
|
||||
This class is an implementation of `UserModel`. Every method just delegates to the `UserModel` it was instantiated with.
|
||||
We override the `setUsername()` method of this delegate class to synchronize automatically with the property file.
|
||||
For your providers, you can use this to _intercept_ other methods on the local `UserModel` to perform synchronization
|
||||
with your extern store. For example, get methods could make sure that the local store is in sync. Set methods
|
||||
keep external store in sync with local one.
|
||||
|
||||
NOTE: If your provider is implementing the `UserRegistrationProvider` interface, your `removeUser()` method does not
|
||||
need to remove the user from local storage. The runtime will automatically perform this operation. Also
|
||||
note that `removeUser()` will be invoked before it is removed from local storage.
|
||||
|
||||
|
||||
==== ImportedUserValidation Interface
|
||||
|
||||
If you remember earlier in this chapter, we discussed how querying for a user worked. Local storage is queried first,
|
||||
if the user is found there, then the query ends. This is a problem for our above implementation as we want
|
||||
to proxy the local `UserModel` so that we can keep usernames in sync. The User Storage SPI has a callback for whenever
|
||||
a linked local user is loaded from the local database.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
package org.keycloak.storage.user;
|
||||
public interface ImportedUserValidation {
|
||||
/**
|
||||
* If this method returns null, then the user in local storage will be removed
|
||||
*
|
||||
* @param realm
|
||||
* @param user
|
||||
* @return null if user no longer valid
|
||||
*/
|
||||
UserModel validate(RealmModel realm, UserModel user);
|
||||
}
|
||||
----
|
||||
|
||||
Whenever a linked local user is loaded, if the user storage provider class implements this interface, then the
|
||||
`validate()` method is called. Here you can proxy the local user passed in as a parameter and return it. That
|
||||
new `UserModel` will be used. You can also optionally do a check to see if the user exists still in the external store.
|
||||
if `validate()` returns `null`, then the local user will be removed from the database.
|
||||
|
||||
==== ImportSynchronization Interface
|
||||
|
||||
With the import strategy you can see that it would be possible for the local user copy could get out of sync with
|
||||
external storage. For example, maybe a user has been removed from the external store. The User Storage SPI has
|
||||
an additional interface you can implement to deal with this. `org.keycloak.storage.user.ImportSynchronization`.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
package org.keycloak.storage.user;
|
||||
|
||||
public interface ImportSynchronization {
|
||||
SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model);
|
||||
SynchronizationResult syncSince(Date lastSync, KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model);
|
||||
}
|
||||
----
|
||||
|
||||
This interface is implemented by the provider factory. Once this interface is implemented by the provider factory,
|
||||
the admin console management page for the provider will show additional options. There is a button that will allow
|
||||
you to manually force a synchronization. This invokes the `ImportSynchronization.sync()` method. Also, some additional
|
||||
configuration options will show up that allow you to automatically schedule a synchronization. Automatic syncs invoke
|
||||
the `syncSince()` method.
|
87
server_development/topics/user-storage/javaee.adoc
Normal file
87
server_development/topics/user-storage/javaee.adoc
Normal file
|
@ -0,0 +1,87 @@
|
|||
|
||||
=== Leveraging Java EE
|
||||
|
||||
The user storage providers can be packaged within any Java EE component so long as you set up the `META-INF/services`
|
||||
file correctly to point to your providers. For example, if your provider needs to use third party libraries, you
|
||||
can package up your provider within an ear and store these third pary libraries in the ear's `lib/` directory.
|
||||
Also note that provider jars can make use of the `jboss-deployment-structure.xml` file that EJBs, WARS, and EARs
|
||||
can use in a JBoss/Wildfly environment. See the JBoss/Wildfly documentation for more details on this file. It
|
||||
allows you to pull in external dependencies among other fine grain actions.
|
||||
|
||||
Implementations of `UserStorageProviderFactory` are required to be plain java objects. But, we also currently support
|
||||
implementing `UserStorageProvider` classes as Stateful EJBs. This is especially useful if you want to use JPA
|
||||
to connect to a relational store. This is how you would do it:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Stateful
|
||||
@Local(EjbExampleUserStorageProvider.class)
|
||||
public class EjbExampleUserStorageProvider implements UserStorageProvider,
|
||||
UserLookupProvider,
|
||||
UserRegistrationProvider,
|
||||
UserQueryProvider,
|
||||
CredentialInputUpdater,
|
||||
CredentialInputValidator,
|
||||
OnUserCache
|
||||
{
|
||||
@PersistenceContext
|
||||
protected EntityManager em;
|
||||
|
||||
protected ComponentModel model;
|
||||
protected KeycloakSession session;
|
||||
|
||||
public void setModel(ComponentModel model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
public void setSession(KeycloakSession session) {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
|
||||
@Remove
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
...
|
||||
}
|
||||
----
|
||||
|
||||
You have to define the `@Local` annotation and specify your provider class there. If you don't do this, EJB will
|
||||
not proxy the user correctly and your provider won't work.
|
||||
|
||||
You must put the `@Remove` annotation on the `close()` method of your provider. If you don't, the stateful bean
|
||||
will never be cleaned up and you may eventually see error messages.
|
||||
|
||||
Implementations of `UserStorageProviderFactory` are required to be plain java objects. Your factory class would
|
||||
perform a JNDI lookup of the Stateful EJB in its create() method.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
public class EjbExampleUserStorageProviderFactory
|
||||
implements UserStorageProviderFactory<EjbExampleUserStorageProvider> {
|
||||
|
||||
@Override
|
||||
public EjbExampleUserStorageProvider create(KeycloakSession session, ComponentModel model) {
|
||||
try {
|
||||
InitialContext ctx = new InitialContext();
|
||||
EjbExampleUserStorageProvider provider = (EjbExampleUserStorageProvider)ctx.lookup(
|
||||
"java:global/user-storage-jpa-example/" + EjbExampleUserStorageProvider.class.getSimpleName());
|
||||
provider.setModel(model);
|
||||
provider.setSession(session);
|
||||
return provider;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
This example also assumes that you've defined a JPA deployment in the same jar as the provider. This means a `persistence.xml`
|
||||
file as well as any JPA `@Entity` classes.
|
||||
|
||||
WARNING: When doing JPA any additional datasource you use must be an XA datasource. The {{book.project.name}} datasource
|
||||
is non-xa. If you interact with two or more non-xa datasource in the same transaction, the server will barf with
|
||||
an error message. You can only have one non-xa resource in a single transaction.
|
||||
|
||||
See the JBoss/Wildfly manual for more details on deploying an XA datasource.
|
||||
|
95
server_development/topics/user-storage/migration.adoc
Normal file
95
server_development/topics/user-storage/migration.adoc
Normal file
|
@ -0,0 +1,95 @@
|
|||
|
||||
=== Migrating from an Earlier User Federation SPI
|
||||
|
||||
NOTE: This chapter is only applicable if you have implemented a provider using the earlier (and now removed)
|
||||
User Federation SPI.
|
||||
|
||||
In Keycloak version 2.4.0 and earlier there was a User Federation SPI. Red Hat Single Sign-On version 7.0, although unsupported, also had
|
||||
this earlier SPI available as well. This earlier User Federation SPI has been removed from Keycloak version 2.5.0 and Red Hat Single Sign-On version 7.1.
|
||||
However, if you have written a provider with this earlier SPI, this chapter discusses some strategies you can use to port it.
|
||||
|
||||
|
||||
==== Import vs. Non-Import
|
||||
|
||||
The earlier User Federation SPI required you to create a local copy of a user in the {{book.project.name}}'s database
|
||||
and import information from your external store to the local copy. However, this is no longer a requirement. You can still
|
||||
port your earlier provider as-is, but you should consider whether a non-import strategy might be a better approach.
|
||||
|
||||
Advantages of the import strategy:
|
||||
|
||||
* {{book.project.name}} basically becomes a persistence user cache for your external store. Once the user is imported
|
||||
you'll no longer hit the external store, thus taking load off of it.
|
||||
* If you are moving to {{book.project.name}} as your official user store and deprecating the earlier external store, you
|
||||
can slowly migrate applications to use {{book.project.name}}. When all applications have been migrated, unlink the
|
||||
imported user, and retire the earlier legacy external store.
|
||||
|
||||
There are some obvious disadvantages though to using an import strategy:
|
||||
|
||||
* Looking up a user for the first time will require multiple updates to {{book.project.name}} database. This can
|
||||
be a big performance loss under load and put a lot of strain on the {{book.project.name}} database. The user federated
|
||||
storage approach will only store extra data as needed and might never be used depending on the capabilities of your external store.
|
||||
* With the import approach, you have to keep local keycloak storage and external storage in sync. The User Storage SPI
|
||||
has capability interfaces that you can implement to support synchronization, but this can quickly become painful and messy.
|
||||
|
||||
==== UserFederationProvider vs. UserStorageProvider
|
||||
|
||||
The first thing to notice is that `UserFederationProvider` was a complete interface. You implemented every method
|
||||
in this interface. However, `UserStorageProvider` has instead broken up this interface into multiple capability interfaces that
|
||||
you implement as needed.
|
||||
|
||||
`UserFederationProvider.getUserByUsername()` and `getUserByEmail()` have exact equivalents in the new SPI. The difference
|
||||
between the two is how you import. If you are going to continue with an import strategy, you no longer call
|
||||
`KeycloakSession.userStorage().addUser()' to create the user locally. Instead you call `KeycloakSession.userLocalStorage().addUser()`.
|
||||
The `userStorage()` method no longer exists.
|
||||
|
||||
The `UserFederationProvider.validateAndProxy()` method has been moved to an optional capability interface, `ImportedUserValidation`.
|
||||
You want to implement this interface if you are porting your earlier provider as-is.
|
||||
Also note that in the earlier SPI, this method was called every time the user was accessed, even if the local user is in the cache.
|
||||
In the later SPI, this method is only called when the local user is loaded from local storage. If the local user is cached,
|
||||
then the `ImportedUserValidation.validate()` method is not called at all.
|
||||
|
||||
The `UserFederationProvider.isValid()` method no longer exists in the later model.
|
||||
|
||||
The `UserFederationProvider` methods `synchronizeRegistrations()`, `registerUser()`, and `removeUser()` have been
|
||||
moved to the `UserRegistrationProvider` capability interface. This new interface is optional to implement so if your
|
||||
provider does not support creating and removing users, you don't have to implement it. If your earlier provider had switch
|
||||
to toggle support for registering new users, this is supported in the new SPI, returning `null` from
|
||||
`UserRegistrationProvider.addUser()` if the provider doesn't support adding users.
|
||||
|
||||
The earlier `UserFederationProvider` methods centered around credentials are now encapsulated in the `CredentialInputValidator`
|
||||
and `CredentialInputUpdater` interfaces, which are also optional to implement depending on if you support validating or
|
||||
updating credentials. Credential management used to exist in `UserModel` methods. These also have been moved to the
|
||||
`CredentialInputValidator` and `CredentialInputUpdater` interfaces.
|
||||
One thing to note that if you do not implement the `CredentialInputUpdater` interface, then
|
||||
any credentials provided by your provider can be overridden locally in {{book.project.name}} storage. So if you want
|
||||
your credentials to be read-only, implement the `CredentialInputUpdater.updateCredential()` method and
|
||||
return a `ReadOnlyException`.
|
||||
|
||||
The `UserFederationProvider` query methods such as `searchByAttributes()` and `getGroupMembers()` are now encapsulated
|
||||
in an optional interface `UserQueryProvider`. If you do not implement this interface, then users will not be viewable
|
||||
in the admin console. You'll still be able to login though.
|
||||
|
||||
==== UserFederationProviderFactory vs. UserStorageProviderFactory
|
||||
|
||||
The synchronization methods in the earlier SPI are now encapsulated within an optional `ImportSynchronization` interface.
|
||||
If you have implemented synchronization logic, then have your new `UserStorageProviderFactory` implement the
|
||||
`ImportSynchronization` interface.
|
||||
|
||||
==== Upgrading to a New Model
|
||||
|
||||
The User Storage SPI instances are stored in a completely different set of relational tables or Mongo schema. {{book.project.name}}
|
||||
automatically runs a migration script. If any earlier User Federation providers are deployed for a realm, they are converted
|
||||
to the later storage model as is, including the `id` of the data. This migration will only happen if a User Storage provider exists
|
||||
with the same provider ID (i.e., "ldap", "kerberos") as the earlier User Federation provider.
|
||||
|
||||
So, knowing this there are different approaches you can take.
|
||||
|
||||
. You can remove the earlier provider in your earlier {{book.project.name}} deployment. This will remove all local linked copies
|
||||
of imported users. Then, when you upgrade {{book.project.name}}, just deploy and configure your new provider for your realm.
|
||||
. The second option is to write your new provider making sure it has the same provider ID: `UserStorageProviderFactory.getId()`.
|
||||
Make sure this provider is in the `deploy/` directory of the new {{book.project.name}} installation. Boot the server, and have
|
||||
the built-in migration script convert from the earlier data model to the later data model. In this case all your earlier linked imported
|
||||
users will work and be the same.
|
||||
|
||||
If you have decided to get rid of the import strategy and rewrite your User Storage provider, we suggest that you remove the earlier provider
|
||||
before upgrading {{book.project.name}}. This will remove linked local imported copies of any user you imported.
|
59
server_development/topics/user-storage/model-interfaces.adoc
Normal file
59
server_development/topics/user-storage/model-interfaces.adoc
Normal file
|
@ -0,0 +1,59 @@
|
|||
|
||||
=== Model Interfaces
|
||||
|
||||
Most of the methods defined in the _capability_ _interfaces_ either return or are passed in representations of a user. These representations are defined
|
||||
by the `org.keycloak.models.UserModel` interface. App developers are required to implement this interface. It provides
|
||||
a mapping between the external user store and the user metamodel that {{book.project.name}} uses.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
package org.keycloak.models;
|
||||
|
||||
public interface UserModel extends RoleMapperModel {
|
||||
String getId();
|
||||
|
||||
String getUsername();
|
||||
void setUsername(String username);
|
||||
|
||||
String getFirstName();
|
||||
void setFirstName(String firstName);
|
||||
|
||||
String getLastName();
|
||||
void setLastName(String lastName);
|
||||
|
||||
String getEmail();
|
||||
void setEmail(String email);
|
||||
...
|
||||
}
|
||||
----
|
||||
|
||||
`UserModel` implementations provide access to read and update metadata about the user including things like username, name, email,
|
||||
role and group mappings, as well as other arbitrary attributes.
|
||||
|
||||
There are other model classes within the `org.keycloak.models` package the represent other parts of the {{book.project.name}}
|
||||
metamodel: `RealmModel`, `RoleModel`, `GroupModel`, and `ClientModel`.
|
||||
|
||||
==== Storage Ids
|
||||
|
||||
One really import method of `UserModel` is the `getId()` method. When implementing `UserModel` developers must be aware
|
||||
of the user id format. The format must be
|
||||
|
||||
----
|
||||
"f:" + component id + ":" + external id
|
||||
----
|
||||
|
||||
The {{book.project.name}} runtime often has to lookup users by their user id. The user id contains enough information
|
||||
so that the runtime does not have to query every single `UserStorageProvider` in the system to find the user.
|
||||
|
||||
The component id is the id returned from `ComponentModel.getId()`. The `ComponentModel` is passed in as a parameter
|
||||
when creating the provider class so you can get it from there. The external id is information your provider class
|
||||
needs to find the user in the external store. This is often a username or a uid. For example, it might look something
|
||||
like this:
|
||||
|
||||
----
|
||||
f:332a234e31234:wburke
|
||||
----
|
||||
|
||||
When the runtime does a lookup by id, the id is parsed to obtain the component id. The component id is used to
|
||||
locate the `UserStorageProvider` that was originally used to load the user. That provider is then passed the id.
|
||||
The provider again parses the id to obtain the external id it will use to locate the user in external user storage.
|
17
server_development/topics/user-storage/packaging.adoc
Normal file
17
server_development/topics/user-storage/packaging.adoc
Normal file
|
@ -0,0 +1,17 @@
|
|||
|
||||
=== Packaging and Deployment
|
||||
|
||||
User Storage providers are packaged in a jar and deployed or undeployed to the {{book.project.name}} runtime in the same exact
|
||||
way as you would deploy something in the JBoss/Wildfly application server. You can either copy the jar directly to
|
||||
the `deploy/` directory if the server, or use the JBoss CLI to execute the deployment. In order for {{book.project.name}}
|
||||
to recognize the provider, there's one special file you need to add to the jar: `META-INF/services/org.keycloak.storage.UserStorageProviderFactory`.
|
||||
This file must contain a line separated list of fully qualified classnames of use `UserStorageProviderFactory` implementation.
|
||||
|
||||
----
|
||||
org.keycloak.examples.federation.properties.ClasspathPropertiesStorageFactory
|
||||
org.keycloak.examples.federation.properties.FilePropertiesStorageFactory
|
||||
----
|
||||
|
||||
{{book.project.name}} supports hot deployment of these provider jars. You'll also see later in this chapter that you can
|
||||
package within and as Java EE components.
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
|
||||
=== Provider Capability Interfaces
|
||||
|
||||
If you've examined the `UserStorageProvider` interface closely you may be scratching your head a bit because it does
|
||||
not define any methods for locating or managing users. These methods are actually defined in other _capability_
|
||||
_interfaces_ depending on what scope of capabilities your external user store can provide and execute on. For example,
|
||||
some external stores are read only and can only do simple queries and credential validation. You will only be required to implement the
|
||||
_capability_ _interfaces_ for the features you are able to. Here's a list of interfaces that you can implement:
|
||||
|
||||
|
||||
|===
|
||||
|SPI|Description
|
||||
|
||||
|`org.keycloak.storage.user.UserLookupProvider`|This interface is required if you want to be able to login with users from this external store. Most (all?) providers implement this interface.
|
||||
|`org.keycloak.storage.user.UserQueryProvider`|Defines complex queries that are used to locate one or more users. You must implement this interface if you want to view and manager users from the administration console.
|
||||
|`org.keycloak.storage.user.UserRegistrationProvider`|Implement this interface if your provider supports adding and removing users.
|
||||
|`org.keycloak.storage.user.UserBulkUpdateProvider`|Implement this interface if your provider supports bulk update of a set of users.
|
||||
|`org.keycloak.credential.CredentialInputValidator`|Implement this interface if your provider can validate one or more different credential types. (i.e. can validate a password)
|
||||
|`org.keycloak.credential.CredentialInputUpdater`|Implement this interface if your provider supports updating one more different credential types.
|
||||
|===
|
||||
|
117
server_development/topics/user-storage/provider-interfaces.adoc
Normal file
117
server_development/topics/user-storage/provider-interfaces.adoc
Normal file
|
@ -0,0 +1,117 @@
|
|||
|
||||
=== Provider Interfaces
|
||||
|
||||
When building an implementation of the User Storage SPI you have to define a provider class and a provider factory.
|
||||
Provider class instances are created per transaction by provider factories.
|
||||
Provider classes do all the heavy lifting of user lookup and other user operations. They must implement the
|
||||
`org.keycloak.storage.UserStorageProvider` interface.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
package org.keycloak.storage;
|
||||
|
||||
public interface UserStorageProvider extends Provider {
|
||||
|
||||
|
||||
/**
|
||||
* Callback when a realm is removed. Implement this if, for example, you want to do some
|
||||
* cleanup in your user storage when a realm is removed
|
||||
*
|
||||
* @param realm
|
||||
*/
|
||||
default
|
||||
void preRemove(RealmModel realm) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback when a group is removed. Allows you to do things like remove a user
|
||||
* group mapping in your external store if appropriate
|
||||
*
|
||||
* @param realm
|
||||
* @param group
|
||||
*/
|
||||
default
|
||||
void preRemove(RealmModel realm, GroupModel group) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback when a role is removed. Allows you to do things like remove a user
|
||||
* role mapping in your external store if appropriate
|
||||
|
||||
* @param realm
|
||||
* @param role
|
||||
*/
|
||||
default
|
||||
void preRemove(RealmModel realm, RoleModel role) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
----
|
||||
|
||||
You may be thinking that the `UserStorageProvider` interface is pretty sparse? You'll see later in this chapter that
|
||||
there are other mix-in interfaces your provider class may implement to support the meat of user integration.
|
||||
|
||||
`UserStorageProvider` instances are created once per transaction. When the transaction is complete, the
|
||||
`UserStorageProvider.close()` method is invoked and the instance is then garbage collections. Instances are created
|
||||
by provider factories. Provider factories implement the `org.keycloak.storage.UserStorageProviderFactory` interface.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
package org.keycloak.storage;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
|
||||
* @version $Revision: 1 $
|
||||
*/
|
||||
public interface UserStorageProviderFactory<T extends UserStorageProvider> extends ComponentFactory<T, UserStorageProvider> {
|
||||
|
||||
/**
|
||||
* This is the name of the provider and will be showed in the admin console as an option.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
String getId();
|
||||
|
||||
/**
|
||||
* called per Keycloak transaction.
|
||||
*
|
||||
* @param session
|
||||
* @param model
|
||||
* @return
|
||||
*/
|
||||
T create(KeycloakSession session, ComponentModel model);
|
||||
...
|
||||
}
|
||||
----
|
||||
|
||||
Provider factory classses must specify the concrete provider class as a template parameter when implementing the
|
||||
`UserStorageProviderFactory`. This is a must as the runtime will introspect this class to scan for its capabilities
|
||||
(the other interfaces it implements). So for example, if your provider class is named `FileProvider`, then the
|
||||
factory class should look like this:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
public class FileProviderFactory implements UserStorageProviderFactory<FileProvider> {
|
||||
|
||||
public String getId() { return "file-provider"; }
|
||||
|
||||
public FileProvider create(KeycloakSession session, ComponentModel model) {
|
||||
...
|
||||
}
|
||||
----
|
||||
|
||||
The `getId()` method returns the name of the User Storage provider. This id will be displayed in the admin console's
|
||||
`UserFederation` page when you want to enable the provider for a specific realm.
|
||||
|
||||
The `create()` method is responsible for allocating an instance of the provider class. It takes a `org.keycloak.models.KeycloakSession`
|
||||
parameter. This object can be used to lookup other information and metadata as well as provide access to various other
|
||||
components within the runtime. The `ComponentModel` parameter represents how the provider was enabled and configured within
|
||||
a specific realm. It contains the instance id of the enabled provider as well as any configuration you may have specified
|
||||
for it when you enabled through the admin console.
|
||||
|
||||
The `UserStorageProviderFactory` has other capabilities as well which we will go over later in this chapter.
|
||||
|
230
server_development/topics/user-storage/registration-query.adoc
Normal file
230
server_development/topics/user-storage/registration-query.adoc
Normal file
|
@ -0,0 +1,230 @@
|
|||
|
||||
=== Add/Remove User and Query Capability interfaces
|
||||
|
||||
One thing we have not done with our example is allow it to add and remove users or change passwords. Users defined in our example are
|
||||
also not queryable or viewable in the admin console. To add these enhancements, our example provider must implement
|
||||
the `UserQueryProvider` and `UserRegistrationProvider` interfaces.
|
||||
|
||||
==== Implementing UserRegistrationProvider
|
||||
|
||||
To implement adding and removing users from this particular store, we first have to be able to save our properties
|
||||
file to disk.
|
||||
|
||||
.PropertyFileUserStorageProvider
|
||||
[source,java]
|
||||
----
|
||||
public void save() {
|
||||
String path = model.getConfig().getFirst("path");
|
||||
path = EnvUtil.replace(path);
|
||||
try {
|
||||
FileOutputStream fos = new FileOutputStream(path);
|
||||
properties.store(fos, "");
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
Then, the implementation of the `addUser()` and `removeUser()` methods becomes pretty simple.
|
||||
|
||||
.PropertyFileUserStorageProvider
|
||||
[source,java]
|
||||
----
|
||||
public static final String UNSET_PASSWORD="#$!-UNSET-PASSWORD";
|
||||
|
||||
@Override
|
||||
public UserModel addUser(RealmModel realm, String username) {
|
||||
synchronized (properties) {
|
||||
properties.setProperty(username, UNSET_PASSWORD);
|
||||
save();
|
||||
}
|
||||
return createAdapter(realm, username);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean removeUser(RealmModel realm, UserModel user) {
|
||||
synchronized (properties) {
|
||||
if (properties.remove(user.getUsername()) == null) return false;
|
||||
save();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
Notice that when adding a user we set the password value of the property map to be `UNSET_PASSWORD`. We do this as
|
||||
we can't have null values for a property in the property value. We also have to modify the `CredentialInputValidator`
|
||||
methods to reflect this.
|
||||
|
||||
`addUser()` will be called if the provider implements the `UserRegistrationProvider` interface. If your provider has
|
||||
a configuration switch to turn of adding a user, returning `null` from this method will skip the provider and call
|
||||
the next one.
|
||||
|
||||
.PropertyFileUserStorageProvider
|
||||
[source,java]
|
||||
----
|
||||
@Override
|
||||
public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
|
||||
if (!supportsCredentialType(input.getType()) || !(input instanceof UserCredentialModel)) return false;
|
||||
|
||||
UserCredentialModel cred = (UserCredentialModel)input;
|
||||
String password = properties.getProperty(user.getUsername());
|
||||
if (password == null || UNSET_PASSWORD.equals(password)) return false;
|
||||
return password.equals(cred.getValue());
|
||||
}
|
||||
----
|
||||
|
||||
Since we can now save our property file, probably also makes sense to allow password updates.
|
||||
|
||||
.PropertyFileUserStorageProvider
|
||||
[source,java]
|
||||
----
|
||||
@Override
|
||||
public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
|
||||
if (!(input instanceof UserCredentialModel)) return false;
|
||||
if (!input.getType().equals(CredentialModel.PASSWORD)) return false;
|
||||
UserCredentialModel cred = (UserCredentialModel)input;
|
||||
synchronized (properties) {
|
||||
properties.setProperty(user.getUsername(), cred.getValue());
|
||||
save();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
----
|
||||
|
||||
We can now also implement disabling a password too.
|
||||
|
||||
.PropertyFileUserStorageProvider
|
||||
[source,java]
|
||||
----
|
||||
@Override
|
||||
public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
|
||||
if (!credentialType.equals(CredentialModel.PASSWORD)) return;
|
||||
synchronized (properties) {
|
||||
properties.setProperty(user.getUsername(), UNSET_PASSWORD);
|
||||
save();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static final Set<String> disableableTypes = new HashSet<>();
|
||||
|
||||
static {
|
||||
disableableTypes.add(CredentialModel.PASSWORD);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getDisableableCredentialTypes(RealmModel realm, UserModel user) {
|
||||
|
||||
return disableableTypes;
|
||||
}
|
||||
----
|
||||
|
||||
With these methods implemented, you'll now be able to change and disable the password for the user in the admin console.
|
||||
|
||||
==== Implementing UserQueryProvider
|
||||
|
||||
Without implementing `UserQueryProvider` the admin console would not be able to view and manage users that were loaded
|
||||
by our example provider. Let's look at implementing this interface.
|
||||
|
||||
.PropertyFileUserStorageProvider
|
||||
[source,java]
|
||||
----
|
||||
@Override
|
||||
public int getUsersCount(RealmModel realm) {
|
||||
return properties.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserModel> getUsers(RealmModel realm) {
|
||||
return getUsers(realm, 0, Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserModel> getUsers(RealmModel realm, int firstResult, int maxResults) {
|
||||
List<UserModel> users = new LinkedList<>();
|
||||
int i = 0;
|
||||
for (Object obj : properties.keySet()) {
|
||||
if (i++ < firstResult) continue;
|
||||
String username = (String)obj;
|
||||
UserModel user = getUserByUsername(username, realm);
|
||||
users.add(user);
|
||||
if (users.size() >= maxResults) break;
|
||||
}
|
||||
return users;
|
||||
}
|
||||
----
|
||||
|
||||
The `getUser()` method simple iterates the key set of the property file delegating to `getuserByUsername` to load a user.
|
||||
Notice that we are indexing this call based on the `firstResult` and `maxResults` parameter. If your external store
|
||||
doesn't support pagination, you'll have to do similar logic.
|
||||
|
||||
.PropertyFileUserStorageProvider
|
||||
[source,java]
|
||||
----
|
||||
@Override
|
||||
public List<UserModel> searchForUser(String search, RealmModel realm) {
|
||||
return searchForUser(search, realm, 0, Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserModel> searchForUser(String search, RealmModel realm, int firstResult, int maxResults) {
|
||||
List<UserModel> users = new LinkedList<>();
|
||||
int i = 0;
|
||||
for (Object obj : properties.keySet()) {
|
||||
String username = (String)obj;
|
||||
if (!username.contains(search)) continue;
|
||||
if (i++ < firstResult) continue;
|
||||
UserModel user = getUserByUsername(username, realm);
|
||||
users.add(user);
|
||||
if (users.size() >= maxResults) break;
|
||||
}
|
||||
return users;
|
||||
}
|
||||
----
|
||||
|
||||
The first declaration of `searchForUser()` takes a string paraeter. This is supposed to be a string that you use to
|
||||
search username and email attributes to find the user. This string can be a substring which is why we use the `String.contains()`
|
||||
method when doing our search.
|
||||
|
||||
.PropertyFileUserStorageProvider
|
||||
[source,java]
|
||||
----
|
||||
@Override
|
||||
public List<UserModel> searchForUser(Map<String, String> params, RealmModel realm) {
|
||||
return searchForUser(params, realm, 0, Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserModel> searchForUser(Map<String, String> params, RealmModel realm, int firstResult, int maxResults) {
|
||||
// only support searching by username
|
||||
String usernameSearchString = params.get("username");
|
||||
if (usernameSearchString == null) return Collections.EMPTY_LIST;
|
||||
return searchForUser(usernameSearchString, realm, firstResult, maxResults);
|
||||
}
|
||||
----
|
||||
|
||||
The `searchForUser()` method that takes a `Map` parameter can search for a user based on first, last name, username, and email.
|
||||
We only store usernames, so we only search based on usernames. We delegate to `searchForUser()` for this.
|
||||
|
||||
|
||||
.PropertyFileUserStorageProvider
|
||||
[source,java]
|
||||
----
|
||||
@Override
|
||||
public List<UserModel> getGroupMembers(RealmModel realm, GroupModel group, int firstResult, int maxResults) {
|
||||
return Collections.EMPTY_LIST;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserModel> getGroupMembers(RealmModel realm, GroupModel group) {
|
||||
return Collections.EMPTY_LIST;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserModel> searchForUserByUserAttribute(String attrName, String attrValue, RealmModel realm) {
|
||||
return Collections.EMPTY_LIST;
|
||||
}
|
||||
----
|
||||
|
||||
We don't store and groups or attributes, so the other methods just return an empty list.
|
102
server_development/topics/user-storage/rest.adoc
Normal file
102
server_development/topics/user-storage/rest.adoc
Normal file
|
@ -0,0 +1,102 @@
|
|||
|
||||
=== REST Management API
|
||||
|
||||
You can create, remove, and update your user storage provider deployments through the admin REST api. The User Storage SPI
|
||||
is built on top of a generic component interface so you will be using that generic API to manage your providers.
|
||||
|
||||
The REST Component API lives under your realm admin resource.
|
||||
|
||||
----
|
||||
/admin/realms/{realm-name}/components
|
||||
----
|
||||
|
||||
We will only show this REST API interaction with the Java client. Hopefully you can extract how do do this from
|
||||
curl from this api.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
public interface ComponentsResource {
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public List<ComponentRepresentation> query();
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public List<ComponentRepresentation> query(@QueryParam("parent") String parent);
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public List<ComponentRepresentation> query(@QueryParam("parent") String parent, @QueryParam("type") String type);
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public List<ComponentRepresentation> query(@QueryParam("parent") String parent,
|
||||
@QueryParam("type") String type,
|
||||
@QueryParam("name") String name);
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
Response add(ComponentRepresentation rep);
|
||||
|
||||
@Path("{id}")
|
||||
ComponentResource component(@PathParam("id") String id);
|
||||
}
|
||||
|
||||
public interface ComponentResource {
|
||||
@GET
|
||||
public ComponentRepresentation toRepresentation();
|
||||
|
||||
@PUT
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public void update(ComponentRepresentation rep);
|
||||
|
||||
@DELETE
|
||||
public void remove();
|
||||
}
|
||||
|
||||
----
|
||||
|
||||
To create a user storage provider, you must specify the provider id, a provider type of the string `org.keycloak.storage.UserStorageProvider`,
|
||||
as well as the configuration.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
import org.keycloak.admin.client.Keycloak;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
...
|
||||
|
||||
Keycloak keycloak = Keycloak.getInstance(
|
||||
"http://localhost:8080/auth",
|
||||
"master",
|
||||
"admin",
|
||||
"password",
|
||||
"admin-cli");
|
||||
RealmResource realmResource = keycloak.realm("master");
|
||||
RealmRepresentation realm = realmResource.toRepresentation();
|
||||
|
||||
ComponentRepresentation component = new ComponentRepresentation();
|
||||
component.setName("home");
|
||||
component.setProviderId("readonly-property-file");
|
||||
component.setProviderType("org.keycloak.storage.UserStorageProvider");
|
||||
component.setParentId(realm.getId());
|
||||
component.setConfig(new MultivaluedHashMap());
|
||||
component.getConfig().putSingle("path", "~/users.properties");
|
||||
|
||||
realmResource.components().add(component);
|
||||
|
||||
// retrieve a component
|
||||
|
||||
List<ComponentRepresentation> components = realmResource.components().query(realm.getId(),
|
||||
"org.keycloak.storage.UserStorageProvider",
|
||||
"home");
|
||||
component = components.get(0);
|
||||
|
||||
// Update a component
|
||||
|
||||
component.getConfig().putSingle("path", "~/my-users.properties");
|
||||
realmResource.components().component(component.getId()).update(component);
|
||||
|
||||
// Remove a component
|
||||
|
||||
realmREsource.components().component(component.getId()).remove();
|
||||
----
|
325
server_development/topics/user-storage/simple-example.adoc
Normal file
325
server_development/topics/user-storage/simple-example.adoc
Normal file
|
@ -0,0 +1,325 @@
|
|||
=== Simple Read-Only, Lookup Example
|
||||
|
||||
To illustrate the basics of implementing the User Storage SPI let's walk through a simple example. In this chapter
|
||||
you'll see the implementation of a simple `UserStorageProvider` that looks up users in a simple property file. The
|
||||
property file contains username and password definitions and is hardcoded to a specific location on the classpath.
|
||||
The provider will be able to lookup the user by id and username and also be able to validate passwords. Users that
|
||||
originate from this provider will be read only.
|
||||
|
||||
==== Provider Class
|
||||
|
||||
The first thing we will walk through is the `UserStorageProvider` class.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
public class PropertyFileUserStorageProvider implements
|
||||
UserStorageProvider,
|
||||
UserLookupProvider,
|
||||
CredentialInputValidator,
|
||||
CredentialInputUpdater
|
||||
{
|
||||
...
|
||||
}
|
||||
----
|
||||
|
||||
Our provider class, `PropertyFileUserStorageProvider`, implements a bunch of interfaces. It implements the
|
||||
`UserStorageProvider` as that is a base requirement of the SPI. It implements the `UserLookupProvider` interface
|
||||
because we want to be able to login with users stored by this provider. It implements the `CredentialInputValidator`
|
||||
interface because we want to be able to validate passwords entered in via the login screen. Our property file
|
||||
is going to be read only. We implement the `CredentialInputUpdater` because was want to post an error condition
|
||||
when the user's password is attempted to be updated.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
protected KeycloakSession session;
|
||||
protected Properties properties;
|
||||
protected ComponentModel model;
|
||||
// map of loaded users in this transaction
|
||||
protected Map<String, UserModel> loadedUsers = new HashMap<>();
|
||||
|
||||
public PropertyFileUserStorageProvider(KeycloakSession session, ComponentModel model, Properties properties) {
|
||||
this.session = session;
|
||||
this.model = model;
|
||||
this.properties = properties;
|
||||
}
|
||||
----
|
||||
|
||||
The constructor for this provider class is going to store the reference to the `KeycloakSession`, `ComponentModel`, and
|
||||
property file. We'll use all of these later. Also notice that there is a map of loaded users. Whenever we find a user
|
||||
we will store it in this map so that we avoid recreating it again within the same transaction. This is a good practice
|
||||
to do as many providers will need to do this (i.e., one that integrates with JPA). Remember also that provider class
|
||||
instances are created once per transaction and are closed after the transaction completes.
|
||||
|
||||
===== UserLookupProvider implementation
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Override
|
||||
public UserModel getUserByUsername(String username, RealmModel realm) {
|
||||
UserModel adapter = loadedUsers.get(username);
|
||||
if (adapter == null) {
|
||||
String password = properties.getProperty(username);
|
||||
if (password != null) {
|
||||
adapter = createAdapter(realm, username);
|
||||
loadedUsers.put(username, adapter);
|
||||
}
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
protected UserModel createAdapter(RealmModel realm, String username) {
|
||||
return new AbstractUserAdapter(session, realm, model) {
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserModel getUserById(String id, RealmModel realm) {
|
||||
StorageId storageId = new StorageId(id);
|
||||
String username = storageId.getExternalId();
|
||||
return getUserByUsername(username, realm);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserModel getUserByEmail(String email, RealmModel realm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
----
|
||||
|
||||
The `getUserByUsername()` method is invoked by the {{book.project.name}} login page when a user logs in. In our
|
||||
implementation we first check the `loadedUsers` map to see if the user has already been loaded within this transaction.
|
||||
If it hasn't been loaded we look in the property file for the username. If it exists we create an implementation
|
||||
of `UserModel`, store it in `loadedUsers` for future reference and return this instance.
|
||||
|
||||
The `createAdapter()` method uses the helper class `org.keycloak.storage.adapter.AbstractUserAdapter`. This provides
|
||||
a base implementation for `UserModel`. It automatically generates a user id based on the required storage id format
|
||||
using the username of the user as the external id.
|
||||
|
||||
----
|
||||
"f:" + component id + ":" + username
|
||||
----
|
||||
|
||||
Every get method of `AbstractUserAdapter` either returns null or empty collections. However, methods that return
|
||||
role and group mappings will return the default roles and groups configured for the realm for every user. Every set
|
||||
method of `AbstractUserAdapter` will throw a `org.keycloak.storage.ReadOnlyException`. So if you attempt
|
||||
to modify the user in the admin console you will get an error.
|
||||
|
||||
The `getUserById()` method parses the `id` parameter using the `org.keycloak.storage.StorageId' helper class. The
|
||||
`StorageId.getExternalId()` method is invoked to obtain the username embeded in the `id` parameter. The method
|
||||
then delegates to `getUserByUsername()`.
|
||||
|
||||
Emails are not stored at all, so the `getUserByEmail() method
|
||||
|
||||
===== CredentialInputValidator implementation
|
||||
|
||||
Next let's look at the method implementations for `CredentialInputValidator`.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Override
|
||||
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
|
||||
String password = properties.getProperty(user.getUsername());
|
||||
return credentialType.equals(CredentialModel.PASSWORD) && password != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsCredentialType(String credentialType) {
|
||||
return credentialType.equals(CredentialModel.PASSWORD);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
|
||||
if (!supportsCredentialType(input.getType()) || !(input instanceof UserCredentialModel)) return false;
|
||||
|
||||
UserCredentialModel cred = (UserCredentialModel)input;
|
||||
String password = properties.getProperty(user.getUsername());
|
||||
if (password == null) return false;
|
||||
return password.equals(cred.getValue());
|
||||
}
|
||||
----
|
||||
|
||||
The `isConfiguredFor()` method is called by the runtime to determine if a specific credential type is configured for
|
||||
the user. This method checks to see that the password is set for the user.
|
||||
|
||||
The `suportsCredentialType()` method returns whether validation is supported for a specific credential type. We check
|
||||
to see if the credential type is `password`.
|
||||
|
||||
The `isValid()` method is responsible for validating passwords. The `CredentialInput` parameter is really just an abstract
|
||||
interface for all credential types. We make sure that we support the credential type and also that it is an instance
|
||||
of `UserCredentialModel`. When a user logs in through the login page, the plain text of the password input is put into
|
||||
an instance of `UserCredentialModel`. The `isValid()` method checks this value against the plain text password stored
|
||||
in the properties file. A return value of `true` means the password is valid.
|
||||
|
||||
===== CredentialInputUpdater implementation
|
||||
|
||||
As noted before, the only reason we implement the `CredentialInputUpdater` interface in this example is to forbid modifications of
|
||||
user passwords. The reason we have to do this is because otherwise the runtime would allow the password to be overriden
|
||||
in {{book.project.name}} local storage. We'll talk more about this later in this chapter
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Override
|
||||
public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
|
||||
if (input.getType().equals(CredentialModel.PASSWORD)) throw new ReadOnlyException("user is read only for this update");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getDisableableCredentialTypes(RealmModel realm, UserModel user) {
|
||||
return Collections.EMPTY_SET;
|
||||
}
|
||||
----
|
||||
|
||||
The `updateCredential()` method just checks to see if the credential type is password. If it is, a `ReadOnlyException`
|
||||
is thrown.
|
||||
|
||||
==== Provider Factory implementation
|
||||
|
||||
Now that the provider class is complete, we now turn our attention to the provider factory class.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
public class PropertyFileUserStorageProviderFactory
|
||||
implements UserStorageProviderFactory<PropertyFileUserStorageProvider> {
|
||||
|
||||
public static final String PROVIDER_NAME = "readonly-property-file";
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return PROVIDER_NAME;
|
||||
}
|
||||
----
|
||||
|
||||
First thing to notice is that when implementing the `UserStorageProviderFactory` class, you must pass in the concrete
|
||||
provider class implementation as a template parameter. Here we specify the provider class we defined before: `PropertyFileUserStorageProvider`.
|
||||
|
||||
WARNING: If you do not specify the template parameter, your provider will not function. The runtime does class introspection
|
||||
to determine the _capability interfaces_ that the provider implements.
|
||||
|
||||
The `getId()` method identifies the factory in the runtime and will also be the string shown in the admin console when you want
|
||||
to enable a user storage provider for the realm.
|
||||
|
||||
===== Initialization
|
||||
|
||||
[source,java]
|
||||
----
|
||||
private static final Logger logger = Logger.getLogger(PropertyFileUserStorageProviderFactory.class);
|
||||
protected Properties properties = new Properties();
|
||||
|
||||
@Override
|
||||
public void init(Config.Scope config) {
|
||||
InputStream is = getClass().getClassLoader().getResourceAsStream("/users.properties");
|
||||
|
||||
if (is == null) {
|
||||
logger.warn("Could not find users.properties in classpath");
|
||||
} else {
|
||||
try {
|
||||
properties.load(is);
|
||||
} catch (IOException ex) {
|
||||
logger.error("Failed to load users.properties file", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PropertyFileUserStorageProvider create(KeycloakSession session, ComponentModel model) {
|
||||
return new PropertyFileUserStorageProvider(session, model, properties);
|
||||
}
|
||||
----
|
||||
|
||||
The `UserStorageProviderFactory` interface has an optional `init()` method you can implement. When {{book.project.name}}
|
||||
boots up, one and only one instance of each different provider factory. Also at boot time, the `init()` method will
|
||||
be called on each one of these factory instances. There's also a `postInit()` method you can implement as well. After
|
||||
each factory's `init()` method is invoked, their `postInit()` methods will be called.
|
||||
|
||||
In our `init()` method implementation, we find the property file containing our user declarations from the classpath.
|
||||
We then load the `properties` field with the username and password combinations stored there.
|
||||
|
||||
The `Config.Scope` parameter is factory configuration that can be set up
|
||||
within `standalone.xml`, `standalone-ha.xml`, or `domain.xml`.
|
||||
For more information on where the `standalone.xml`, `standalone-ha.xml`, or `domain.xml` file resides see the link:{{book.project.doc_base_url}}{{book.project.doc_info_version_url}}{{book.installguide.link}}[{{book.installguide.name}}].
|
||||
|
||||
For example, by adding the following to `standalone.xml`:
|
||||
|
||||
[source,xml]
|
||||
----
|
||||
<spi name="storage">
|
||||
<provider name="readonly-property-file" enabled="true">
|
||||
<properties>
|
||||
<property name="path" value="/other-users.properties"/>
|
||||
</properties>
|
||||
</provider>
|
||||
</spi>
|
||||
----
|
||||
|
||||
We can specify the classpath of the user property file instead of hard coded it.
|
||||
Then you can retrieve the config in the `PropertyFileUserStorageProviderFactory.init()` :
|
||||
|
||||
[source,java]
|
||||
----
|
||||
public void init(Config.Scope config) {
|
||||
String path = config.get("path");
|
||||
InputStream is = getClass().getClassLoader().getResourceAsStream(path);
|
||||
|
||||
...
|
||||
}
|
||||
----
|
||||
|
||||
===== Create method
|
||||
|
||||
Our last step in creating the provider factory is the `create()` method.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Override
|
||||
public PropertyFileUserStorageProvider create(KeycloakSession session, ComponentModel model) {
|
||||
return new PropertyFileUserStorageProvider(session, model, properties);
|
||||
}
|
||||
----
|
||||
|
||||
We simply allocate the `PropertyFileUserStorageProvider` class. This create method will be called once per transaction.
|
||||
|
||||
==== Packaging and Deployment
|
||||
|
||||
The class files for our provider implementation should be placed in a jar. You also have to declare the provider
|
||||
factory class within the `META-INF/services/org.keycloak.storage.UserStorageProviderFactory` file.
|
||||
|
||||
----
|
||||
org.keycloak.examples.federation.properties.FilePropertiesStorageFactory
|
||||
----
|
||||
|
||||
Once you create the jar you can deploy it using regular JBoss/Wildfly means: copy the jar into the `deploy/` directory
|
||||
or using the JBoss CLI.
|
||||
|
||||
==== Enabling the Provider in Admin Console
|
||||
|
||||
You enable user storage providers per realm within the `User Federation` page in the admin console.
|
||||
|
||||
.User Federation
|
||||
image:../../{{book.images}}/empty-user-federation-page.png[]
|
||||
|
||||
Select the provider we just created from the list: `readonly-property-file`. It brings you to the configuration
|
||||
page for our provider. We don't have anything to configure, so just click the `Save` button.
|
||||
|
||||
.Configured Provider
|
||||
image:../../{{book.images}}/storage-provider-created.png[]
|
||||
|
||||
When you go back to the main `User Federation` page, you'll now see your provider listed.
|
||||
|
||||
.User Federation
|
||||
image:../../{{book.images}}/user-federation-page.png[]
|
||||
|
||||
You will now be able to login with a user declared in the `users.properties` file. Of course, this user will have
|
||||
zero permissions to do anything and will be read only. You can though view the user on its account page after you
|
||||
login.
|
Loading…
Reference in a new issue